commit 0c7ce9e33441853d91dfec7bd0dc979e236ead58 Author: James Coleman Date: Mon Aug 10 17:17:03 2026 -0500 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..588217c --- /dev/null +++ b/.gitignore @@ -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__/ + diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e2645ab --- /dev/null +++ b/.golangci.yml @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..83d4a90 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2d4e1d4 --- /dev/null +++ b/Makefile @@ -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.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a4a414 --- /dev/null +++ b/README.md @@ -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 +platform‑agnostic `Rule` struct and the module translates them to whatever +backend is actually running on the host. + +Reference documentation: + +```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 --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 +``` diff --git a/apf_linux.go b/apf_linux.go new file mode 100644 index 0000000..07d4818 --- /dev/null +++ b/apf_linux.go @@ -0,0 +1,1951 @@ +package firewall + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +const ( + APFConf = "/etc/apf/conf.apf" + APFAllow = "/etc/apf/allow_hosts.rules" + APFDeny = "/etc/apf/deny_hosts.rules" + // APFPreroute and APFPostroute are apf's user routing-rule files, sourced as + // shell during a (re)start. Like the pre-hook below, this backend uses them as a + // raw-iptables fallback for rules apf's native config and allow/deny files cannot + // express — here the routing-stage cases, which in practice are NAT: destination + // NAT (DNAT/Redirect) is written into preroute.rules and source NAT + // (SNAT/Masquerade) into postroute.rules. + APFPreroute = "/etc/apf/preroute.rules" + APFPostroute = "/etc/apf/postroute.rules" + // APFHook is apf's pre-hook, run after the flush but before apf adds its own + // rules. This library writes the iptables rules for features apf's native + // config cannot express directly into this hook. + APFHook = "/etc/apf/hook_pre.sh" +) + +// APF manages the firewall through apf's config files and a managed pre-hook. +type APF struct { + ConfigChanged bool + // rulePrefix tags rules this library creates so they can be told apart + // from foreign rules. In allow_hosts.rules/deny_hosts.rules it is + // prepended to the comment written on the line above each rule; + // conf.apf port/icmp-list rules carry no per-rule comment and so + // cannot carry the tag. + rulePrefix string + // ipv6Enabled mirrors conf.apf's USE_IPV6. With it off (the shipped default) apf + // enforces no IPv6 at all: its shell logic no-ops a bare IPv6 host in + // allow_hosts.rules/deny_hosts.rules (apf_trust.sh trust_hosts()) and the native + // IG_ICMPV6_TYPES/EG_ICMPV6_TYPES lists (cports.common), and ip6tables is never + // flushed on (re)load (apf_ipt.sh ipt6()) — so a hook-injected ip6tables line + // would be re-appended on every reload and outlive its own removal. AddRule + // therefore rejects every concrete-IPv6 rule rather than write one apf will + // never enforce. + ipv6Enabled bool + // stopActions caches conf.apf's STOP settings, loaded on first use: + // deny_hosts commonly holds thousands of entries and each parsed line + // consults a STOP action, so per-line conf reads are avoided. The library + // never writes these keys. + stopActions map[string]Action +} + +// NewAPF verifies apf is installed and active, then returns a manager bound to rulePrefix. +func NewAPF(ctx context.Context, rulePrefix string) (*APF, error) { + apf := new(APF) + apf.rulePrefix = rulePrefix + + // Confirm apf is enabled under whatever init system the host uses + // (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local). + if !serviceEnabled(ctx, "apf") { + return nil, fmt.Errorf("the apf service is not active or enabled on this server") + } + + // Confirm config files exist. + files := []string{APFConf, APFAllow, APFDeny} + for _, f := range files { + if _, err := os.Stat(f); err != nil { + return nil, fmt.Errorf("the config file %s is missing", f) + } + } + + // Read whether apf's own IPv6 handling is turned on. + useIPv6, err := readConfValue(APFConf, "USE_IPV6") + if err != nil { + return nil, fmt.Errorf("error reading %s: %s", APFConf, err) + } + apf.ipv6Enabled = useIPv6 == "1" + + // Return the new apf object. + return apf, nil +} + +// Type returns the backend identifier for apf. +func (f *APF) Type() string { + return APFType +} + +// Capabilities reports the firewall features apf supports. +func (f *APF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + // IPv6 mirrors ipv6Enabled: with conf.apf's USE_IPV6 off, apf never + // touches ip6tables — an IG_ICMPV6_TYPES entry yields no rule — so neither + // its native config nor the raw-iptables hook yields a rule apf will keep + // in sync across a reload (see ipv6Enabled). + IPv6: f.ipv6Enabled, + PortPair: true, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: false, + DefaultPolicy: false, + RuleCounters: true, + AddressSets: true, + Comments: true, + Negation: true, + RejectAction: true, + FamilyWithoutAddress: true, + // A deny_hosts.rules entry stores no action; apf applies conf.apf's + // ALL_STOP action, so removal matches an entry whatever action is named. + DenyActionFromConfig: true, + } +} + +// GetZone returns no zone; apf has no zone support. +func (f *APF) GetZone(ctx context.Context, iface string) (zoneName string, err error) { + return "", nil +} + +// parsePortToken parses a single apf port token: a port or an underscore +// range (e.g. "6000_7000"). +func (f *APF) parsePortToken(tok string) (PortRange, error) { + lo, hi, isRange := strings.Cut(strings.TrimSpace(tok), "_") + 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 below start") + } + } + return pr, nil +} + +// ParseConnLimit decodes a conf.apf IG_TCP_CLIMIT/IG_UDP_CLIMIT value +// ("port:limit,...", port may be an underscore range) into connection-limit +// rules: apf caps concurrent connections per source and rejects the excess, so +// each entry becomes an inbound reject rule carrying a per-source ConnLimit. +func (f *APF) ParseConnLimit(val string, proto Protocol) (rules []*Rule) { + for _, entry := range strings.Split(val, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + portTok, limitTok, ok := strings.Cut(entry, ":") + if !ok { + continue + } + pr, err := f.parsePortToken(strings.TrimSpace(portTok)) + if err != nil { + continue + } + limit, err := strconv.ParseUint(strings.TrimSpace(limitTok), 10, 32) + if err != nil { + continue + } + // Like the CPORTS lists, IG_TCP_CLIMIT/IG_UDP_CLIMIT are dual-stack, so a + // connection-limit entry carries no family of its own. Report FamilyAny so a + // FamilyAny desired connlimit rule reconciles with its read-back. (APF has no + // egress connection-limit config; connlimit applies only on the input chain.) + rule := &Rule{ + Family: FamilyAny, + Proto: proto, + Action: Reject, + ConnLimit: &ConnLimit{Count: uint(limit), PerSource: true}, + } + portSpecsToRule(rule, []PortRange{pr}) + rules = append(rules, rule) + } + return +} + +// ParseICMPTypes decodes an apf ICMP type list (IG_ICMP_TYPES/EG_ICMP_TYPES for +// proto ICMP, IG_ICMPV6_TYPES/EG_ICMPV6_TYPES for proto ICMPv6) into accept rules, +// one per type. The "all" wildcard (which apf applies as a typeless `-p icmp -j +// ACCEPT`) becomes a rule with a nil ICMPType, matching every type. +func (f *APF) ParseICMPTypes(val string, proto Protocol, dir Direction) (rules []*Rule) { + fam := IPv4 + if proto == ICMPv6 { + fam = IPv6 + } + for _, tok := range strings.Split(val, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + rule := &Rule{Family: fam, Proto: proto, Direction: dir, Action: Accept} + if !strings.EqualFold(tok, "all") { + n, ok := parseICMPTypeFamily(tok, proto == ICMPv6) + if !ok { + continue + } + rule.ICMPType = Ptr(n) + } + rules = append(rules, rule) + } + return +} + +// parseAddr parses an apf address value, stripping the bracket notation used +// to protect IPv6 addresses, and normalizing a zero-network to the empty (any) +// address. It reports the family, or false when the value is not an address. +func (f *APF) parseAddr(v string) (addr string, fam Family, ok bool) { + if strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") { + v = v[1 : len(v)-1] + } + _, network, err := net.ParseCIDR(v) + ip := net.ParseIP(v) + if err != nil && ip == nil { + return "", FamilyAny, false + } + family := IPv4 + if (network != nil && network.IP.To4() == nil) || (ip != nil && ip.To4() == nil) { + family = IPv6 + } + // A zero network (0.0.0.0/0 or ::/0) means "any", represented as empty. + if network != nil { + ones, _ := network.Mask.Size() + if ones == 0 && network.IP.IsUnspecified() { + return "", family, true + } + } + return v, family, true +} + +// parseStopAction maps a conf.apf ALL_STOP/TCP_STOP/UDP_STOP value to the +// action apf actually applies. "DROP", "REJECT" and "PROHIBIT" are valid; +// anything else (including empty) falls back to the stock default of DROP. +// PROHIBIT jumps to apf's own PROHIBIT chain, which rejects with an ICMP +// (un)reachable-prohibited response — the same reject-like semantics as +// REJECT, just a different ICMP code — so it maps to Reject rather than Drop; +// this model has no third action to give it. +func (f *APF) parseStopAction(val string) Action { + switch strings.ToUpper(trimQuotes(strings.TrimSpace(val))) { + case "REJECT", "PROHIBIT": + return Reject + default: + return Drop + } +} + +// readStopAction reads the named STOP setting (ALL_STOP/TCP_STOP/UDP_STOP) +// from a conf.apf-format file, defaulting to the stock DROP when the key is +// absent or the file cannot be read. +func (f *APF) readStopAction(path, key string) Action { + action := Drop + fd, err := os.Open(path) + if err != nil { + return action + } + defer func() { _ = fd.Close() }() + + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := scanner.Text() + if ci := strings.IndexByte(line, '#'); ci >= 0 { + line = line[:ci] + } + k, val, found := strings.Cut(strings.TrimSpace(line), "=") + if !found { + continue + } + if strings.TrimSpace(k) != key { + continue + } + action = f.parseStopAction(val) + } + return action +} + +// stopKey names the conf.apf setting apf actually applies to a deny of the +// given protocol. A bare-address deny_hosts entry (no protocol/port) is dropped +// by trust_hosts's own bare-host branch, which applies ALL_STOP directly. An +// advanced entry (proto:flow:s/d=port:s/d=ip) is instead routed through +// trust_entry_rule, which ignores ALL_STOP entirely and applies TCP_STOP for a +// tcp entry or UDP_STOP for a udp one (files/internals/apf_trust.sh). The three +// settings default to DROP and are otherwise fully independent, so an entry read +// or matched under the wrong key can report or accept the wrong action whenever +// an operator sets them differently. +func (f *APF) stopKey(proto Protocol) string { + switch proto { + case TCP: + return "TCP_STOP" + case UDP: + return "UDP_STOP" + default: + return "ALL_STOP" + } +} + +// confStopActions returns conf.apf's three STOP settings, read once per manager +// and cached (see the stopActions field). +func (f *APF) confStopActions() map[string]Action { + if f.stopActions == nil { + f.stopActions = map[string]Action{ + "ALL_STOP": f.readStopAction(APFConf, "ALL_STOP"), + "TCP_STOP": f.readStopAction(APFConf, "TCP_STOP"), + "UDP_STOP": f.readStopAction(APFConf, "UDP_STOP"), + } + } + return f.stopActions +} + +// denyActionFor returns the action apf applies to a deny of the given protocol, +// from conf.apf's STOP settings (see stopKey). +func (f *APF) denyActionFor(proto Protocol) Action { + stops := f.confStopActions() + // A TCPUDP deny is one protocol-less advanced line, which apf applies with + // TCP_STOP on the tcp rule it derives and UDP_STOP on the udp one. It therefore + // has a single native action only when the two settings agree; when they differ + // the rule cannot be one Rule with one Action, so report ActionInvalid and let + // addRule route it to the hook, whose lines carry the action verbatim. + if proto == TCPUDP { + if stops["UDP_STOP"] != stops["TCP_STOP"] { + return ActionInvalid + } + return stops["TCP_STOP"] + } + return stops[f.stopKey(proto)] +} + +// resolveAction resolves the action to stamp on a rule parsed from — or +// matched against — allow_hosts/deny_hosts. base is Accept for allow_hosts +// (returned unchanged, since parseStopAction never yields Accept and +// allow_hosts has no per-protocol distinction) or the ALL_STOP-derived action +// for deny_hosts. A deny_hosts entry in apf's advanced syntax with a concrete +// tcp or udp protocol is not governed by ALL_STOP (see stopKey), so its +// action is re-derived from the matching TCP_STOP/UDP_STOP setting instead. +func (f *APF) resolveAction(base Action, proto Protocol) Action { + if base == Accept { + return base + } + switch proto { + case TCP, UDP, TCPUDP: + // denyActionFor reports ActionInvalid for a TCPUDP rule whose TCP_STOP and + // UDP_STOP disagree — the signal addRule uses to route such a rule to the hook, + // since no single native line carries two actions. A rule being read back still + // needs a usable action, so fall back to the file's own (a foreign + // protocol-less deny line written outside this library can hit this). + if a := f.denyActionFor(proto); a != ActionInvalid { + return a + } + return base + default: + return base + } +} + +// splitAdvFields splits an apf advanced rule on ':' while leaving colons inside +// bracketed IPv6 addresses (e.g. [2001:db8::1]) intact. +func (f *APF) splitAdvFields(s string) []string { + var fields []string + depth, start := 0, 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '[': + depth++ + case ']': + if depth > 0 { + depth-- + } + case ':': + if depth == 0 { + fields = append(fields, s[start:i]) + start = i + 1 + } + } + } + return append(fields, s[start:]) +} + +// ParseAdvRule decodes an apf advanced allow/deny rule of the form +// proto:flow:s/d=port:s/d=ip. IPv6 addresses use bracket notation. +func (f *APF) ParseAdvRule(val string, action Action) (r *Rule) { + r = &Rule{} + + for _, fld := range f.splitAdvFields(val) { + switch { + case strings.EqualFold(fld, "tcp"): + r.Proto = TCP + case strings.EqualFold(fld, "udp"): + r.Proto = UDP + case strings.EqualFold(fld, "in"): + r.Direction = DirInput + case strings.EqualFold(fld, "out"): + r.Direction = DirOutput + case strings.HasPrefix(fld, "s="): + // The source field is either an address or, when it is not, a source + // port (a single port or an underscore range; apf's port field takes + // no comma list). + v := strings.TrimPrefix(fld, "s=") + if addr, fam, ok := f.parseAddr(v); ok { + r.Family = fam + r.Source = addr + continue + } + pr, err := f.parsePortToken(v) + if err != nil { + return nil + } + sourcePortSpecsToRule(r, []PortRange{pr}) + case strings.HasPrefix(fld, "d="): + // Mirror the s= branch: the destination field is either an address or a + // single destination port (a single port or an underscore range; apf's + // port field takes no comma list). + v := strings.TrimPrefix(fld, "d=") + if addr, fam, ok := f.parseAddr(v); ok { + r.Family = fam + r.Destination = addr + continue + } + pr, err := f.parsePortToken(v) + if err != nil { + return nil + } + portSpecsToRule(r, []PortRange{pr}) + } + } + + // An advanced line with no protocol field covers both transports: apf's trust + // parser derives a `-p tcp` rule and a `-p udp` rule from it. Report that as + // TCPUDP, not ProtocolAny, so the rule reads back as the one that was written and + // is not mistaken for an every-protocol match. + if r.Proto == ProtocolAny { + r.Proto = TCPUDP + } + + // The action depends on the protocol just parsed (see resolveAction), so + // it is resolved last rather than stamped up front. + r.Action = f.resolveAction(action, r.Proto) + + return +} + +// ParseIPList reads an apf allow_hosts/deny_hosts file and returns the rules it +// holds, each carrying any full-line comment that precedes it (see +// scanCommentGroups for the comment-attachment convention). +func (f *APF) ParseIPList(filePath string, action Action) (rules []*Rule, err error) { + // Read the allow_hosts/deny_hosts rule list. + fd, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer func() { _ = fd.Close() }() + + err = scanCommentGroups(fd, f.rulePrefix, nil, func(g commentGroup) error { + // Strip an inline trailing comment (not a rule comment). + line := trimInlineComment(g.line) + if line == "" { + return nil + } + // parseListLine classifies an advanced (s=/d= option) line or a plain + // address line — one bidirectional DirAny rule authored in the inbound + // frame (Source=X) — and skips anything else. + rule := f.parseListLine(line, action) + if rule == nil { + return nil + } + rule.Comment, rule.HasPrefix = prefixedComment(f.rulePrefix, g.comment) + rules = append(rules, rule) + return nil + }) + if err != nil { + return nil, err + } + return +} + +// ParsePorts decodes an apf comma-separated port list into accept rules, one per port entry. +func (f *APF) ParsePorts(val string, proto Protocol, dir Direction) (rules []*Rule) { + for _, port := range strings.Split(val, ",") { + port = strings.TrimSpace(port) + if port == "" { + continue + } + + pr, err := f.parsePortToken(port) + if err != nil { + continue + } + // apf's IG_*_CPORTS/EG_*_CPORTS lists are dual-stack — a single list applied + // to both the ip and ip6 tables — so a port entry carries no family of its + // own. Report FamilyAny (not IPv4) so a FamilyAny desired rule, which a bare + // tcp/udp port carries by default, reconciles with its own read-back. + rule := &Rule{ + Family: FamilyAny, + Proto: proto, + Direction: dir, + Action: Accept, + } + portSpecsToRule(rule, []PortRange{pr}) + rules = append(rules, rule) + } + return +} + +// hook returns the managed pre-hook script used to inject iptables rules for +// features apf's native config cannot express. +func (f *APF) hook() *hookScript { + return newHookScript(f.rulePrefix, APFHook, 0750, f.ipv6Enabled) +} + +// --- live counters ----------------------------------------------------------- + +// trustChainDirection reports the direction a row in one of apf's trust chains +// stands for. TALLOW/TDENY (and their global twins) are entered from both INPUT +// and OUTPUT, so the chain itself names no direction — apf writes the inbound +// half of an entry as a source match and the outbound half as a destination +// match, and that is the only signal the row carries. A row matching on neither +// side, or on both, keeps DirAny. +func (f *APF) trustChainDirection(fields []string) Direction { + src, dst := false, false + for _, tok := range fields { + switch tok { + case "-s", "--source": + src = true + case "-d", "--destination": + dst = true + } + } + switch { + case src && !dst: + return DirInput + case dst && !src: + return DirOutput + } + return DirAny +} + +// liveChain reports the direction a live chain's rows stand for, and whether the +// chain is one apf's rules reach at all. The trust chains carry no direction of +// their own, so their rows are classified from the row itself. +func (f *APF) liveChain(chain string, fields []string) (Direction, bool) { + switch chain { + case "INPUT": + return DirInput, true + case "OUTPUT": + return DirOutput, true + case "FORWARD": + return DirForward, true + case "TALLOW", "TDENY", "TGALLOW", "TGDENY": + return f.trustChainDirection(fields), true + } + // Every other chain is one of apf's internal sanity/policy chains, whose rows + // stand for no rule this backend reports. + return DirInput, false +} + +// icmpLimit reads conf.apf's ICMP_LIM, the rate apf attaches to every ICMP type +// it accepts. The rate is apf's own framing rather than part of the rule — an +// IG_ICMP_TYPES entry decodes to a plain accept — so a live ICMP row carrying +// exactly this rate has it cleared before matching. A missing or malformed +// setting yields no rate, which leaves such rows unmatched rather than risking +// clearing a rate a rule genuinely carries. +func (f *APF) icmpLimit() *RateLimit { + val, err := readConfValue(APFConf, "ICMP_LIM") + if err != nil || val == "" { + return nil + } + rate, unit, err := parseRateToken(val) + if err != nil { + return nil + } + return &RateLimit{Rate: rate, Unit: unit} +} + +// parseLiveRules decodes counter-annotated `iptables-save -c` output into the +// rules apf's chains hold, reading the ICMP framing rate from conf.apf. +func (f *APF) parseLiveRules(out []string, fam Family) []*Rule { + return f.decodeLiveRules(out, fam, f.icmpLimit()) +} + +// decodeLiveRules is parseLiveRules with the ICMP framing rate supplied. apf +// writes a rule much as this backend models it, so the only framing to undo is +// the rate it attaches to every accepted ICMP type, and the direction its trust +// chains leave to the row. +func (f *APF) decodeLiveRules(out []string, fam Family, icmpLim *RateLimit) []*Rule { + return decodeLiveRows(out, func(row liveRow) (*Rule, bool) { + dir, ok := f.liveChain(row.chain, row.fields) + if !ok { + return nil, false + } + rule, ok := parseLiveRow(row, dir, fam) + if !ok { + return nil, false + } + // Drop apf's ICMP rate framing so the row reads as the plain accept a + // conf.apf ICMP-type entry decodes to. + if icmpLim != nil && rule.Proto.IsICMP() && rule.Action == Accept && + rule.RateLimit != nil && *rule.RateLimit == *icmpLim { + rule.RateLimit = nil + } + return rule, true + }) +} + +// mergeLiveCounters copies the kernel's packet/byte counters onto the rules read +// from conf.apf, the trust lists and the pre-hook. +func (f *APF) mergeLiveCounters(ctx context.Context, rules []*Rule, fam Family) { + targets := countableRules(rules, fam) + if len(targets) == 0 { + return + } + applyLiveCounters(targets, f.parseLiveRules(liveSaveLines(ctx, fam), fam)) +} + +// GetRules returns the current filter rules read from conf.apf, the allow/deny lists, and the pre-hook. +func (f *APF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + // Read rules from conf.apf + fd, err := os.Open(APFConf) + if err != nil { + return nil, err + } + + // denyAction is the action apf applies to a bare-address deny (conf.apf + // ALL_STOP, default DROP). Capture it in this same pass so the deny list can + // be stamped without re-reading conf.apf for that common case; a tcp/udp + // advanced deny_hosts entry is re-resolved from TCP_STOP/UDP_STOP instead (see + // resolveAction, used by ParseAdvRule as ParseIPList parses each line). + denyAction := Drop + + // Scan each line. + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + // Get the line. + line := scanner.Text() + + // Remove comments. + ci := strings.IndexByte(line, '#') + if ci >= 0 { + line = line[:ci] + } + + // Trim spaces. + line = strings.TrimSpace(line) + + // Ignore zero lines. + if len(line) == 0 { + continue + } + + // Parse key/value. + key, val, found := strings.Cut(line, "=") + if !found { + continue + } + key = strings.TrimSpace(key) + val = trimQuotes(strings.TrimSpace(val)) + + // Parse rules. + switch key { + case "ALL_STOP": + denyAction = f.parseStopAction(val) + case "IG_TCP_CPORTS": + rules = append(rules, f.ParsePorts(val, TCP, DirInput)...) + case "IG_UDP_CPORTS": + rules = append(rules, f.ParsePorts(val, UDP, DirInput)...) + case "EG_TCP_CPORTS": + rules = append(rules, f.ParsePorts(val, TCP, DirOutput)...) + case "EG_UDP_CPORTS": + rules = append(rules, f.ParsePorts(val, UDP, DirOutput)...) + case "IG_ICMP_TYPES": + rules = append(rules, f.ParseICMPTypes(val, ICMP, DirInput)...) + case "EG_ICMP_TYPES": + rules = append(rules, f.ParseICMPTypes(val, ICMP, DirOutput)...) + case "IG_ICMPV6_TYPES": + rules = append(rules, f.ParseICMPTypes(val, ICMPv6, DirInput)...) + case "EG_ICMPV6_TYPES": + rules = append(rules, f.ParseICMPTypes(val, ICMPv6, DirOutput)...) + case "IG_TCP_CLIMIT": + rules = append(rules, f.ParseConnLimit(val, TCP)...) + case "IG_UDP_CLIMIT": + rules = append(rules, f.ParseConnLimit(val, UDP)...) + } + } + + _ = fd.Close() + if err := scanner.Err(); err != nil { + return nil, err + } + + // Read the allowed IP rule list. + ipRules, err := f.ParseIPList(APFAllow, Accept) + if err != nil { + return nil, err + } + rules = append(rules, ipRules...) + + // Read the denied IP rule list, stamped with the action apf actually applies + // (captured above from conf.apf ALL_STOP, default DROP) rather than a fixed + // Reject, so a managed Drop rule reads back as Drop and reconciles without churn. + ipRules, err = f.ParseIPList(APFDeny, denyAction) + if err != nil { + return nil, err + } + rules = append(rules, ipRules...) + + // Read the iptables rules injected through the apf pre-hook (state, + // interface, logging, rate-limit, icmpv6). + hookRules, err := f.hook().getRules() + if err != nil { + return nil, err + } + rules = append(rules, hookRules...) + + // apf's config files carry no packet/byte counters — the kernel does — so + // merge them from the live ruleset (RuleCounters). + f.mergeLiveCounters(ctx, rules, IPv4) + f.mergeLiveCounters(ctx, rules, IPv6) + + // Every entry above is reported as apf stores it, and several apf entries cover + // more than one axis on their own: a CPORTS or CLIMIT entry is dual-stack and + // decodes to FamilyAny, a protocol-less advanced line decodes to TCPUDP, and a + // bare allow_hosts/deny_hosts IP is one bidirectional line that decodes to DirAny. + // What apf keys separately — the TCP and UDP CPORTS lists, the IG_ and EG_ prefixes, + // the per-family hook lines — stays several rules. + return +} + +// portToken renders a port spec in apf notation: a port or an underscore +// range. +func (f *APF) portToken(pr PortRange) 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) +} + +// editConnLimit renders a CLIMIT config line with a rule's "port:limit" entry +// added or removed, preserving the other entries, and records a config change. +func (f *APF) editConnLimit(key, val string, r *Rule, remove bool) string { + portTok := f.portToken(r.PortSpecs()[0]) + var kept []string + present := false + for _, tok := range strings.Split(val, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + p, _, ok := strings.Cut(tok, ":") + if ok && strings.TrimSpace(p) == portTok { + present = true + if remove { + f.ConfigChanged = true + continue + } + // Replace the entry in place. Record a config change only when the + // count actually differs so Reload runs apf --restart to apply the new + // limit; an unchanged count must not trigger a spurious restart. + newTok := fmt.Sprintf("%s:%d", portTok, r.ConnLimit.Count) + if strings.TrimSpace(tok) != newTok { + f.ConfigChanged = true + } + kept = append(kept, newTok) + continue + } + kept = append(kept, tok) + } + if !remove && !present { + kept = append(kept, fmt.Sprintf("%s:%d", portTok, r.ConnLimit.Count)) + f.ConfigChanged = true + } + return fmt.Sprintf(`%s="%s"`, key, strings.Join(kept, ",")) +} + +// icmpTokens returns the conf.apf type token(s) an icmp/icmpv6 accept rule +// contributes to its type list: the numeric type, or "all" when the rule matches +// every type (a nil ICMPType, which apf applies as a typeless `-p icmp -j ACCEPT`). +func (f *APF) icmpTokens(r *Rule) []string { + if r.ICMPType == nil { + return []string{"all"} + } + return []string{strconv.Itoa(int(*r.ICMPType))} +} + +// isConnLimitRule reports whether a rule maps onto conf.apf's +// IG_TCP_CLIMIT/IG_UDP_CLIMIT: a per-source cap on concurrent inbound +// connections to a single tcp/udp port (or range) with no address, rejecting the +// excess. +func (f *APF) isConnLimitRule(r *Rule) bool { + return r.ConnLimit != nil && r.ConnLimit.PerSource && + !r.IsOutput() && (r.Proto == TCP || r.Proto == UDP) && + r.Source == "" && r.Destination == "" && + len(r.PortSpecs()) == 1 && r.Action == Reject +} + +// portTokens renders the rule's ports as apf config tokens. +func (f *APF) portTokens(r *Rule) []string { + specs := r.PortSpecs() + tokens := make([]string, len(specs)) + for i, sp := range specs { + tokens[i] = f.portToken(sp) + } + return tokens +} + +// EditRulePort returns the conf.apf line with the rule's tokens added to or removed from the list that key manages. +func (f *APF) EditRulePort(orig, key, val string, r *Rule, remove bool) string { + // A connection-limit rule is expressed solely through the CLIMIT config; it + // must never also add or remove its port from an accept port list, or + // RemoveRule would close a port the caller never opened and a round-trip + // would report a spurious accept rule alongside the connlimit. + if r.ConnLimit != nil && key != "IG_TCP_CLIMIT" && key != "IG_UDP_CLIMIT" { + return orig + } + + // Determine which config list this key manages and the tokens the rule + // contributes to it. Non-matching keys are returned untouched. + // A CPORTS list is per-transport, so a TCPUDP rule contributes its ports to both + // the TCP and the UDP list, and reads back as one rule per list. coversProtocol + // gates each list: TCPUDP covers either, a concrete transport only its own. + var wantTokens []string + switch key { + case "IG_TCP_CPORTS": + if r.IsOutput() || !coversProtocol(r.Proto, TCP) { + return orig + } + wantTokens = f.portTokens(r) + case "IG_UDP_CPORTS": + if r.IsOutput() || !coversProtocol(r.Proto, UDP) { + return orig + } + wantTokens = f.portTokens(r) + case "EG_TCP_CPORTS": + if !r.IsOutput() || !coversProtocol(r.Proto, TCP) { + return orig + } + wantTokens = f.portTokens(r) + case "EG_UDP_CPORTS": + if !r.IsOutput() || !coversProtocol(r.Proto, UDP) { + return orig + } + wantTokens = f.portTokens(r) + case "IG_ICMP_TYPES": + if r.IsOutput() || r.Proto != ICMP { + return orig + } + wantTokens = f.icmpTokens(r) + case "EG_ICMP_TYPES": + if !r.IsOutput() || r.Proto != ICMP { + return orig + } + wantTokens = f.icmpTokens(r) + case "IG_ICMPV6_TYPES": + if r.IsOutput() || r.Proto != ICMPv6 { + return orig + } + wantTokens = f.icmpTokens(r) + case "EG_ICMPV6_TYPES": + if !r.IsOutput() || r.Proto != ICMPv6 { + return orig + } + wantTokens = f.icmpTokens(r) + case "IG_TCP_CLIMIT": + // CLIMIT tokens are "port:limit", edited independently of the port lists. + if !f.isConnLimitRule(r) || r.Proto != TCP { + return orig + } + return f.editConnLimit(key, val, r, remove) + case "IG_UDP_CLIMIT": + if !f.isConnLimitRule(r) || r.Proto != UDP { + return orig + } + return f.editConnLimit(key, val, r, remove) + default: + return orig + } + + // Canonicalize tokens for comparison. An ICMP-type list may hold either a + // numeric type or a name (e.g. "echo-request"), and the read path resolves names + // to numbers; comparing the list's raw token against the numeric token this code + // emits would never match a name-based (foreign) entry, so a remove would keep it + // and an add would append a numeric duplicate. Fold each token to its resolved + // number, using the family the key implies; other keys + // compare verbatim. + canon := func(tok string) string { return tok } + switch key { + case "IG_ICMP_TYPES", "EG_ICMP_TYPES", "IG_ICMPV6_TYPES", "EG_ICMPV6_TYPES": + v6 := key == "IG_ICMPV6_TYPES" || key == "EG_ICMPV6_TYPES" + canon = func(tok string) string { + if n, ok := parseICMPTypeFamily(tok, v6); ok { + return strconv.Itoa(int(n)) + } + return tok + } + } + + // Add or remove the rule's tokens from the comma list, preserving any others. + // want holds the canonical form of every token the rule contributes and + // present the canonical form of every kept token, so removal recognizes a + // token however the file spells it and an add never appends a duplicate; + // kept carries the surviving tokens in their original spelling. + want := make(map[string]bool, len(wantTokens)) + for _, w := range wantTokens { + want[canon(w)] = true + } + present := make(map[string]bool) + var kept []string + + // Pass 1: walk the existing config list, dropping a token only on a remove + // and only when it is one of the rule's own; surviving tokens keep their + // original spelling and are tracked by canonical form for the add pass. + for _, tok := range strings.Split(val, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + c := canon(tok) + // On a remove, an existing token that matches one of the rule's tokens is + // the one we are deleting: skip it and flag the file as changed. + if remove && want[c] { + f.ConfigChanged = true + continue + } + // Otherwise the token stays. Record its canonical form so the add pass + // below treats it as already present. + kept = append(kept, tok) + present[c] = true + } + + // Pass 2 (add only): append any of the rule's tokens that the existing list + // did not already contain. present carries forward from pass 1, so a token + // the file already had is skipped and we never write a duplicate. + if !remove { + for _, w := range wantTokens { + if cw := canon(w); !present[cw] { + kept = append(kept, w) + present[cw] = true + f.ConfigChanged = true + } + } + } + + // Re-create the configuration with the new list. + return fmt.Sprintf(`%s="%s"`, key, strings.Join(kept, ",")) +} + +// EditConf adds or removes a rule in conf.apf, rewriting the file in place. +// confKeyApplies reports whether a conf.apf key is one the rule's EditRulePort +// edit lands in, mirroring EditRulePort's own routing guards. EditConf uses it +// to detect a config missing the rule's key line (an operator-stripped or older +// conf.apf), where an add would otherwise report success with nothing written. +func (f *APF) confKeyApplies(key string, r *Rule) bool { + switch key { + case "IG_TCP_CPORTS": + return r.ConnLimit == nil && !r.IsOutput() && coversProtocol(r.Proto, TCP) + case "IG_UDP_CPORTS": + return r.ConnLimit == nil && !r.IsOutput() && coversProtocol(r.Proto, UDP) + case "EG_TCP_CPORTS": + return r.ConnLimit == nil && r.IsOutput() && coversProtocol(r.Proto, TCP) + case "EG_UDP_CPORTS": + return r.ConnLimit == nil && r.IsOutput() && coversProtocol(r.Proto, UDP) + case "IG_ICMP_TYPES": + return r.ConnLimit == nil && !r.IsOutput() && r.Proto == ICMP + case "EG_ICMP_TYPES": + return r.ConnLimit == nil && r.IsOutput() && r.Proto == ICMP + case "IG_ICMPV6_TYPES": + return r.ConnLimit == nil && !r.IsOutput() && r.Proto == ICMPv6 + case "EG_ICMPV6_TYPES": + return r.ConnLimit == nil && r.IsOutput() && r.Proto == ICMPv6 + case "IG_TCP_CLIMIT": + return f.isConnLimitRule(r) && r.Proto == TCP + case "IG_UDP_CLIMIT": + return f.isConnLimitRule(r) && r.Proto == UDP + } + return false +} + +func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error { + // Open the standard config file; EditRulePort rewrites the port, icmp-type + // and CLIMIT list lines the rule applies to. + fd, err := os.Open(APFConf) + if err != nil { + return err + } + + // Stage the rewrite, preserving conf.apf's mode and ownership. + af, err := newAtomicFile(APFConf, 0644) + if err != nil { + _ = fd.Close() + return err + } + defer af.Abort() + + // Parse config one line at a time, adding the port rule. + keyMatched := false + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + // Get the line. + orig := scanner.Text() + line := orig + + // Remove comments. + ci := strings.IndexByte(line, '#') + if ci >= 0 { + line = line[:ci] + } + + // Trim spaces. + line = strings.TrimSpace(line) + + // Ignore zero lines. + if len(line) == 0 { + _, _ = fmt.Fprintln(af, orig) + continue + } + + // Parse key/value. + key, val, found := strings.Cut(line, "=") + if !found { + _, _ = fmt.Fprintln(af, orig) + continue + } + key = strings.TrimSpace(key) + val = trimQuotes(strings.TrimSpace(val)) + + // Parse rules. + if f.confKeyApplies(key, r) { + keyMatched = true + } + orig = f.EditRulePort(orig, key, val, r, remove) + _, _ = fmt.Fprintln(af, orig) + } + + _ = fd.Close() + + // A read error means the rewritten file is truncated; discard it. + if serr := scanner.Err(); serr != nil { + return serr + } + + // An add against a config missing the rule's key line would write nothing + // while reporting success — the rule's port or type would simply never + // open. Removal of a rule whose key line is absent is a plain no-op. + if !remove && !keyMatched { + return fmt.Errorf("conf.apf carries no config list for this rule") + } + + // Move new file into place, preserving mode and ownership. + return af.Commit() +} + +// addrField renders an address for an advanced rule, wrapping an IPv6 address +// in brackets so it survives the colon-separated field format. +func (f *APF) addrField(addr string) string { + if strings.Contains(addr, ":") { + return "[" + addr + "]" + } + return addr +} + +// MarshalAdvRule encodes a rule as an apf advanced allow/deny line: an optional +// protocol field, a direction, one port-flow field (a source or a destination port) +// and one address field, joined by ":". It validates nothing; addRule/RemoveRule +// route every shape the line cannot carry elsewhere first (see needsHook). +func (f *APF) MarshalAdvRule(r *Rule) string { + // apf's advanced rule carries an optional protocol field, and treats a line + // without one as both transports: its trust parser emits a `-p tcp` rule and a + // `-p udp` rule for it. So TCPUDP is written by omitting the field. + var parts []string + switch r.Proto { + case TCP: + parts = append(parts, "tcp") + case UDP: + parts = append(parts, "udp") + case TCPUDP: + // No protocol field: apf reads that as tcp plus udp. + } + if r.IsOutput() { + parts = append(parts, "out") + } else { + parts = append(parts, "in") + } + // The port-flow field: a source port or a destination port. apf's field holds a + // single port or one underscore range; needsHook routes a multi-port list to the + // hook, so only one spec ever arrives. + if specs := r.SourcePortSpecs(); len(specs) == 1 { + parts = append(parts, "s="+f.portToken(specs[0])) + } else if specs := r.PortSpecs(); len(specs) == 1 { + parts = append(parts, "d="+f.portToken(specs[0])) + } + if r.Source != "" { + parts = append(parts, "s="+f.addrField(r.Source)) + } else if r.Destination != "" { + parts = append(parts, "d="+f.addrField(r.Destination)) + } + return strings.Join(parts, ":") +} + +// parseListLine parses one apf allow_hosts/deny_hosts rule line into the rule it +// holds: an advanced rule, or a plain address line, which is a single bidirectional +// DirAny rule matching every protocol. It returns nil for a line that is neither, +// which the caller passes through untouched. The action comes from the file +// (allow_hosts is accept, deny_hosts takes the drop/reject action conf.apf sets); +// the line encodes none of its own. +func (f *APF) parseListLine(line string, action Action) *Rule { + if strings.Contains(line, "=") { + return f.ParseAdvRule(line, action) + } + fam, ok := parseAddrFamily(line) + if !ok { + return nil + } + return &Rule{Direction: DirAny, Family: fam, Source: line, Action: action} +} + +// listRows returns the allow_hosts/deny_hosts rows a rule materializes into, in write +// order, or none for a shape the trust files cannot hold. The rule must already carry +// the action its file implies (see EditIPList's match), since each row's read-back +// form is compared against lines stamped with it. +// +// A rule never fans out across transports: apf's trust parser reads a protocol-less +// advanced line as a `-p tcp` rule plus a `-p udp` rule, so MarshalAdvRule writes a +// TCPUDP rule as a single line. A port-only deny does fan out per family: apf requires +// an address in the advanced line's field position, and the "any" network placeholder +// it uses there is family-specific, so a family-neutral rule would otherwise silently +// become IPv4-only. The fan-out covers the families apf actually enforces (see +// filterFamiliesIPv6; with conf.apf's USE_IPV6 off that is IPv4 alone). parseAddr +// normalizes the placeholder back to an empty address, so each row reads back as the +// address-less rule it stands for. +func (f *APF) listRows(action Action, match *Rule) []ruleLine { + hasIP := match.Source != "" || match.Destination != "" + var rows []ruleLine + switch { + case hasIP && (match.HasPorts() || match.HasSourcePorts()): + // A port rule with an address is an advanced rule. + rows = append(rows, ruleLine{line: f.MarshalAdvRule(match), read: match}) + case hasIP: + // A bare all-protocol host allow/deny: a single address matching every + // protocol. apf's trust files hold no other portless address shape — a + // concrete-protocol host or a source+destination pair — so AddRule diverts + // those to the raw-iptables hook (shapeNeedsHook) and never reaches here with + // one. A direct caller of this exported writer that supplies such a shape gets + // a best-effort single-address write, not a guard. + addr := match.Source + if addr == "" { + addr = match.Destination + } + // The plain line is bidirectional and names its address as the source, which is + // the frame the scan reads it back in. + read := *match + read.Direction = DirAny + read.Source, read.Destination = addr, "" + rows = append(rows, ruleLine{line: addr, read: &read}) + case action != Accept && match.HasPorts(): + specs := match.PortSpecs() + for _, fam := range filterFamiliesIPv6(f.ipv6Enabled, match) { + placeholder := "0.0.0.0/0" + if fam.impliedFamily() == IPv6 { + // MarshalAdvRule brackets an IPv6 literal for the colon-separated format. + placeholder = "::/0" + } + // Pin the row to the one shape apf's single port-flow field holds: one + // destination port. needsHook routes a multi-port list and a source-port match + // to the hook before AddRule reaches here, so this only bounds a direct caller + // of this exported writer — without it MarshalAdvRule would emit a portless + // line (denying the whole protocol) for a list, or prefer the source port over + // the destination one. + read := *fam + read.Port, read.Ports = 0, specs[:1] + read.SourcePort, read.SourcePorts = 0, nil + // The row reads back address-less; only the line carries the placeholder. + row := read + if row.IsOutput() { + row.Destination = placeholder + } else { + row.Source = placeholder + } + rows = append(rows, ruleLine{line: f.MarshalAdvRule(&row), read: &read}) + } + } + return rows +} + +// EditIPList adds or removes a rule in an apf allow_hosts/deny_hosts file, rewriting +// it in place. An add expands the rule into the rows it materializes into (see +// listRows), notes which of them the file already holds, and appends only the rest, +// so a rule that fans out across families is completed rather than duplicated on +// every reconcile. A removal drops every line the target covers. +func (f *APF) EditIPList(ctx context.Context, filePath string, action Action, r *Rule, remove bool) error { + // Read the allow_hosts/deny_hosts rule list. + fd, err := os.Open(filePath) + if err != nil { + return err + } + defer func() { _ = fd.Close() }() + + // Stage the rewrite, preserving the list file's mode and ownership. + af, err := newAtomicFile(filePath, 0644) + if err != nil { + return err + } + defer af.Abort() + + // A deny_hosts entry takes on the drop/reject action set in apf.conf (and + // allow_hosts is accept), so a rule read back is stamped with the file's + // action. Resolve the incoming rule's action the same way via resolveAction + // before matching. + match := *r + match.Action = f.resolveAction(action, r.Proto) + // The rows an add must end up with, and which of them the scan finds already in + // the file. A removal wants no rows: it matches the target against each line + // directly, since a line it must drop need not be one this library would write. + var rows []ruleLine + if !remove { + rows = f.listRows(action, &match) + } + present := make([]bool, len(rows)) + + // Stream the file's comment-attached groups so a removed rule takes its + // comment with it and every kept line copies through verbatim. + err = scanCommentGroups(fd, f.rulePrefix, nil, func(g commentGroup) error { + keep := func() { + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + } + // Strip an inline trailing comment for matching, but preserve the + // original line (with its inline note) when copying it through. + line := trimInlineComment(g.line) + // A line neither form parses is not a rule; pass it — and any blank or + // detached comment — through untouched. + var cur *Rule + if line != "" { + cur = f.parseListLine(line, action) + } + if cur == nil { + keep() + return nil + } + + // A removal drops every line the target covers, along with its comment. A + // family-neutral target touches each of the concrete lines it was written as; + // that coverage is folded into EqualForRemoval. + if remove { + if cur.EqualForRemoval(&match, true) { + f.ConfigChanged = true + return nil + } + keep() + return nil + } + + // An add keeps every line and only notes which wanted rows the file already + // covers, so the tail writes the rest. Coverage rather than a text compare, so + // a row is satisfied by an existing line that spans it (a protocol-less TCPUDP + // line absorbing a tcp row) and by one spelled differently but meaning the same. + for i := range rows { + if !present[i] && cur.EqualForDedup(rows[i].read, true) { + present[i] = true + } + } + keep() + return nil + }) + // A read error means the rewritten file is truncated; discard it. + if err != nil { + return err + } + + // Append the wanted rows the file does not already hold. A rule that fans out is + // completed row by row: when only a subset is present (the IPv4 line but not its + // IPv6 twin, from a prior single-family add or a manual edit) the missing rows + // must still be written, or that family stays open while the library reports the + // rule in force. + writeComment := func() { + if c := combineComment(f.rulePrefix, r.Comment); c != "" { + _, _ = fmt.Fprintln(af, "# "+c) + } + } + for i, row := range rows { + if present[i] { + continue + } + f.ConfigChanged = true + writeComment() + _, _ = fmt.Fprintln(af, row.line) + } + + // Move new file into place, preserving mode and ownership. + return af.Commit() +} + +// isConfRule reports whether a rule is managed in conf.apf: an address-less +// accept rule of ports (TCP/UDP lists) or ICMP/ICMPv6 types (a nil type is the +// "all" wildcard). +func (f *APF) isConfRule(r *Rule) bool { + if r.Source != "" || r.Destination != "" || r.Action != Accept { + return false + } + return r.HasPorts() || r.Proto == ICMP || r.Proto == ICMPv6 +} + +// nativeICMPv6 reports whether an ICMPv6 rule can be carried by apf's native +// IG_ICMPV6_TYPES/EG_ICMPV6_TYPES lists (an address-less accept, optionally typed) +// and so belongs in conf.apf rather than the raw-iptables hook. The shared +// ruleNeedsHook diverts every ICMPv6 rule to the hook, since not every backend it +// serves has a native v6 type list; apf overrides that only for the rules its config +// can actually express, leaving an ICMPv6 rule that also needs state/interface/log/ +// rate matching (which conf.apf cannot carry) on the hook path. +func (f *APF) nativeICMPv6(r *Rule) bool { + return r.Proto == ICMPv6 && r.State == 0 && r.InInterface == "" && r.OutInterface == "" && + !r.Log && r.RateLimit == nil && f.isConfRule(r) +} + +// barePortAccept reports whether a rule is an address-less tcp/udp port accept — +// the shape apf stores either in a dual-stack conf.apf CPORTS list (a FamilyAny +// port) or, per family, through the raw-iptables hook (a single-family port, or a +// FamilyAny added as a v4 hook rule plus a v6 hook rule). Both the add-time hook +// decision (dualStackPortNeedsHook) and the remove-time split/clear +// (removeDualStackPort) build on it. +func (f *APF) barePortAccept(r *Rule) bool { + return onProtocolAxis(r.Proto) && r.HasPorts() && + r.Source == "" && r.Destination == "" && r.Action == Accept +} + +// dualStackPortNeedsHook reports whether a bare tcp/udp port accept pinned to a +// single family must be injected through the hook. apf's IG_*_CPORTS/EG_*_CPORTS +// lists are dual-stack — one list applied to both the ip and ip6 tables — so they +// express only a FamilyAny port; the lists have no per-family form to pin one to. +// A single-family port accept is written per-family through the hook instead, +// whose iptables (or ip6tables) rule carries just that family; removing one family +// of a FamilyAny CPORTS entry splits it (see removeDualStackPort). ICMP keeps its +// concrete family (its type lists are per-family), so this gates on a port match. +func (f *APF) dualStackPortNeedsHook(r *Rule) bool { + return f.barePortAccept(r) && r.impliedFamily() != FamilyAny +} + +// needsHook reports whether a rule must be injected through the apf pre-hook as a +// raw iptables rule because apf's native config cannot express it. It is the single +// gate between the hook path and apf's config files: everything it rejects (returns +// true) is written to the hook, everything it accepts (returns false) maps onto +// conf.apf or the allow_hosts/deny_hosts trust files. The shared predicates +// (ruleNeedsHook, shapeNeedsHook, bareHostOneWay) and the two apf shapes RemoveRule +// reuses to route a split (dualStackPortNeedsHook, nativeICMPv6) stay standalone; +// the apf-only, single-use port/connlimit/icmp tests are inlined here as their sole +// caller. +func (f *APF) needsHook(r *Rule) bool { + // Features apf's native config cannot model — connection state, per-rule + // interface, logging, rate limiting, forward-chain routing, ICMPv6, or a + // transport apf does not carry (see ruleNeedsHook) — go to the hook. An ICMPv6 + // type rule is the exception: apf carries it natively in IG_ICMPV6_TYPES/ + // EG_ICMPV6_TYPES (see nativeICMPv6), so it stays out. + if ruleNeedsHook(r) && !f.nativeICMPv6(r) { + return true + } + // A shape no native apf form holds — a one-way bare host, a source+destination + // pair, a concrete-protocol portless host, an advanced-line address/port-flow + // overflow, or a bare protocol match (see shapeNeedsHook) — goes to the hook. A + // native address-less ICMP/ICMPv6 accept is excluded there and routed below. + if shapeNeedsHook(r) { + return true + } + // A multi-port tcp/udp list apf's config cannot carry as one rule: its advanced + // rule holds a single port or one underscore range, and a conf.apf CPORTS comma + // list stores each port as an independent token that reads back as its own rule, + // so an address-less multi-port accept would never round-trip whole. Every list + // goes to the hook's iptables multiport match instead, which keeps it on one line. + if onProtocolAxis(r.Proto) && + (len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1) { + return true + } + // A connection limit conf.apf's IG_*_CLIMIT cannot express — anything but a + // per-source cap on a single address-less inbound tcp/udp port rejecting the + // excess (isConnLimitRule) — goes to the hook's `-m connlimit` match. + if r.ConnLimit != nil && !f.isConnLimitRule(r) { + return true + } + // An ICMPv4 rule apf's IG_ICMP_TYPES/EG_ICMP_TYPES lists cannot express (they + // match a type on the whole zone, so only an address-less accept is native, and + // one carrying an address or a non-accept action is not) goes to the hook's + // `iptables -p icmp` match. ICMPv6 is routed by ruleNeedsHook above, not here. + if r.Proto == ICMP && !f.isConfRule(r) { + return true + } + // A single-family bare tcp/udp port accept: apf's CPORTS lists are dual-stack, so + // only a FamilyAny port is native; a single-family one is written per-family + // through the hook (see dualStackPortNeedsHook, which RemoveRule also uses). + return f.dualStackPortNeedsHook(r) +} + +// addRule is AddRule's implementation, with the IPv6 gate optional. Restore +// passes enforceIPv6Gate false so it can reproduce a Backup snapshot's exact +// prior state, including inert entries the gate would reject as fresh no-op +// writes. +func (f *APF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6Gate bool) error { + // Reject a concrete-IPv6 rule when apf's own IPv6 handling is off, ahead of every + // routing decision below: neither conf.apf nor the pre-hook can carry one that apf + // will keep in sync (see ipv6Enabled). Checking here rather than past the hook + // branch also keeps a DirAny rule from writing its input half before its output + // half is rejected. + if enforceIPv6Gate && !f.ipv6Enabled && r.impliedFamily() == IPv6 { + return fmt.Errorf("apf's IPv6 handling is disabled (conf.apf USE_IPV6 is not \"1\"): %w", ErrUnsupported) + } + + // A DirAny rule maps to a single native construct only as a bare-host plain line; + // every other DirAny shape fans out into a concrete input rule plus its swapped + // output rule, each routed independently (a half may itself need the hook). + if r.Direction == DirAny && !dirAnyPlainLine(r) { + for _, sub := range expandDirections(r) { + if err := f.addRule(ctx, zoneName, sub, enforceIPv6Gate); err != nil { + return err + } + } + return nil + } + + // Verify the rule is valid with iptables. + if err := r.validate(); err != nil { + return fmt.Errorf("%v: %w", err, ErrUnsupported) + } + + // Any shape apf's native config cannot express (a stateful/interface/logged/ + // rate-limited rule, a one-way or concrete-protocol host, a source+destination + // pair, a source-and-destination port match, a multi-port list, an address-less + // source-port match, a non-native connection limit, a non-native ICMPv4 rule, or + // a single-family port accept) is + // injected as a raw iptables rule through the apf pre-hook. See needsHook for + // each clause; everything past this gate maps onto apf's own config files. + if f.needsHook(r) { + changed, err := f.hook().edit(r, false) + f.ConfigChanged = f.ConfigChanged || changed + return err + } + + // A native connection-limit rule maps onto conf.apf's IG_*_CLIMIT lists (a + // non-native one was diverted to the hook above by needsHook). + if r.ConnLimit != nil { + return f.EditConf(ctx, r, false) + } + + // Address-less accept rules (dual-stack port lists, icmp types) live in conf.apf. + if f.isConfRule(r) { + return f.EditConf(ctx, r, false) + } + + // Otherwise edit allow_hosts.rules for accepts, deny_hosts.rules for denies. A + // bare protocol match (no address, no port) never reaches here — needsHook routed + // it to the pre-hook above — so every rule at this point carries an address. + if r.Action == Accept { + return f.EditIPList(ctx, APFAllow, Accept, r, false) + } + // A deny_hosts entry carries no action of its own: apf applies conf.apf's ALL_STOP + // action to a bare-address entry, or its TCP_STOP/UDP_STOP action to a tcp/udp + // advanced one (see stopKey). A deny whose action matches that is written natively; + // one that differs has no native form, so it is injected through the pre-hook + // instead, whose iptables rule carries the exact action. A DirAny bare-host deny is + // expanded to its two concrete directions first, since each hook line is one-way. + denyAction := f.denyActionFor(r.Proto) + if r.Action != denyAction { + for _, sub := range expandDirections(r) { + changed, err := f.hook().edit(sub, false) + f.ConfigChanged = f.ConfigChanged || changed + if err != nil { + return err + } + } + return nil + } + return f.EditIPList(ctx, APFDeny, denyAction, r, false) +} + +// AddRule adds a rule to apf, routing it to conf.apf, the allow/deny lists, or the pre-hook. +func (f *APF) AddRule(ctx context.Context, zoneName string, r *Rule) error { + return f.addRule(ctx, zoneName, r, true) +} + +// InsertRule is unsupported: APF organizes rules in config files, not an ordered list. +func (f *APF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + return unsupportedOrdering(f.Type()) +} + +// MoveRule is unsupported for the same reason as InsertRule. +func (f *APF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + return unsupportedOrdering(f.Type()) +} + +// removePlainHost drops the bidirectional plain allow_hosts/deny_hosts line backing +// the DirAny rule e, choosing the list by the rule's action. +func (f *APF) removePlainHost(ctx context.Context, e *Rule) error { + if e.Action == Accept { + return f.EditIPList(ctx, APFAllow, Accept, e, true) + } + return f.EditIPList(ctx, APFDeny, f.denyActionFor(e.Proto), e, true) +} + +// removeBareHostOneWay removes a one-way bare-address host rule. Such a rule is +// stored either as its own hook rule or as one direction of a bidirectional plain +// allow_hosts/deny_hosts line (a DirAny rule). When a matching plain line exists, +// split it: drop the line and re-add the surviving opposite direction as a hook rule +// so the untargeted direction keeps its coverage. +func (f *APF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule) error { + existing, err := f.GetRules(ctx, zoneName) + if err != nil { + return err + } + for _, e := range existing { + if e.Direction != DirAny || !e.EqualForRemoval(r, true) { + continue + } + // The host is stored as a bidirectional plain line; drop it, then re-add the + // surviving direction as a hook rule. + if err := f.removePlainHost(ctx, e); err != nil { + return err + } + if s := splitDualRowDirection(e, r); s != nil { + // An IPv6 plain line left over from when USE_IPV6 was on is not + // enforced with it off, and a hook-injected ip6tables line would + // outlive its own removal (see ipv6Enabled); there is no coverage + // to preserve, so no v6 survivor is written. + if !f.ipv6Enabled && s.impliedFamily() == IPv6 { + return nil + } + changed, err := f.hook().edit(s, false) + f.ConfigChanged = f.ConfigChanged || changed + return err + } + return nil + } + // Not stored as a plain line; remove the one-way hook rule. + changed, err := f.hook().edit(r, true) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// cPortsKey returns the conf.apf CPORTS list a tcp/udp port rule of the given +// direction lives in, or "" for a protocol with no such list. +func (f *APF) cPortsKey(proto Protocol, output bool) string { + dir := "IG" + if output { + dir = "EG" + } + switch proto { + case TCP: + return dir + "_TCP_CPORTS" + case UDP: + return dir + "_UDP_CPORTS" + } + return "" +} + +// portInCPorts reports whether a bare port accept is stored in conf.apf's CPORTS +// lists rather than as its own hook rule. A TCPUDP rule contributes to both the TCP +// and the UDP list, so it is CPORTS-backed only when every transport it covers is +// present — if one list is missing the port, that transport lives in the hook and +// the whole rule must be treated as hook-backed. cPortsKey is only ever asked about +// a concrete transport, since expandProtocols has split the rule first. +func (f *APF) portInCPorts(r *Rule) (bool, error) { + for _, sub := range expandProtocols(r) { + val, err := readConfValue(APFConf, f.cPortsKey(sub.Proto, sub.IsOutput())) + if err != nil { + return false, err + } + found := false + for _, e := range f.ParsePorts(val, sub.Proto, sub.Direction) { + if e.EqualForRemoval(sub, true) { + found = true + break + } + } + if !found { + return false, nil + } + } + return true, nil +} + +// removeDualStackPort removes a single-family bare tcp/udp port accept. Such a rule +// is stored either as its own per-family hook rule or as one family of a dual-stack +// conf.apf CPORTS entry (a FamilyAny port). Read the CPORTS list the port would live +// in — the rule view alone cannot tell a genuine CPORTS entry from a pair of +// single-family hook rules — and split it when present: drop the dual-stack +// entry and re-add the surviving opposite family as a hook rule, so the untargeted +// family keeps its coverage. Otherwise the rule is a per-family hook rule. +func (f *APF) removeDualStackPort(ctx context.Context, r *Rule) error { + inCPorts, err := f.portInCPorts(r) + if err != nil { + return err + } + if inCPorts { + // Drop the dual-stack CPORTS entry (family-agnostic), then re-add the + // surviving opposite family through the hook. + if err := f.EditConf(ctx, r, true); err != nil { + return err + } + surviving := *r + surviving.Family = oppositeFamily(r.impliedFamily()) + // With USE_IPV6 off apf never enforced the entry's IPv6 half, and a + // hook-injected ip6tables line is re-appended on every reload and + // outlives its own removal (see ipv6Enabled), so there is no + // coverage to preserve and no v6 survivor to write. + if !f.ipv6Enabled && surviving.Family == IPv6 { + return nil + } + changed, err := f.hook().edit(&surviving, false) + f.ConfigChanged = f.ConfigChanged || changed + return err + } + // Not stored in CPORTS; remove the per-family hook rule. + changed, err := f.hook().edit(r, true) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// removeFamilyAnyPort removes a FamilyAny address-less bare tcp/udp port accept. Its +// two families live in a dual-stack conf.apf CPORTS entry (a genuine FamilyAny add), +// in a v4+v6 pair in the hook (two separate concrete-family adds), or split across +// both. A read cannot tell which, so remove the rule from both backings — EditConf +// drops it from the CPORTS list and the hook edit drops both per-family rows, and +// each no-ops when the rule is absent — clearing every cell the target covers, +// wherever it lives. Unlike the single-family path there is no surviving family +// to re-express, so no split is needed. +func (f *APF) removeFamilyAnyPort(ctx context.Context, r *Rule) error { + if err := f.EditConf(ctx, r, true); err != nil { + return err + } + changed, err := f.hook().edit(r, true) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// RemoveRule removes a rule from apf, routing it to the config file or pre-hook that holds it. +func (f *APF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + // A non-plain-line DirAny target fans out into its two concrete-direction rules, + // mirroring addRule, so each half is removed from wherever it was written. + if r.Direction == DirAny && !dirAnyPlainLine(r) { + for _, sub := range expandDirections(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // Validate the shape before the hook sweep below: an iptables-inexpressible + // rule (a port on ProtocolAny) exists nowhere apf can hold it, and letting it + // fail inside the hook marshal would return the bare error without the + // sentinel AddRule attaches to the same shape. + if err := r.validate(); err != nil { + return fmt.Errorf("%v: %w", err, ErrUnsupported) + } + + // Clear any hook copy of the rule first, no matter how apf stores it. A rule apf + // carries only in the hook (stateful/interface/logged/rate-limited/icmpv6/ + // hook-only-proto) lives nowhere else, so this is its entire removal; a natively- + // expressible rule may still have a stray hook copy — the library's own + // differing-action deny (see AddRule) or a hand-added duplicate for a shape apf can + // also express natively — that must be cleared before the native entry below. DirAny + // is expanded so both one-way hook lines are matched; a rule with no hook copy makes + // this a harmless no-op. + var err error + for _, sub := range expandDirections(r) { + changed, e := f.hook().edit(sub, true) + f.ConfigChanged = f.ConfigChanged || changed + if e != nil { + err = e + break + } + } + // A rule apf carries only in the hook has no native entry to fall through to, so + // return once its hook copy is cleared (or on any hook error). Returning here also + // keeps such a rule out of the split scans below, whose plain-line/CPORTS checks + // could wrongly split an unrelated coexisting native entry. A native ICMPv6 type + // rule lives in conf.apf, not the hook, so it is excluded and routed there below. + if (ruleNeedsHook(r) && !f.nativeICMPv6(r)) || err != nil { + return err + } + // A one-way bare host rule is stored either as its own hook rule or as one + // direction of a bidirectional plain line; removing it may need to split the + // plain line (see removeBareHostOneWay). + if bareHostOneWay(r) { + return f.removeBareHostOneWay(ctx, zoneName, r) + } + // A single-family bare tcp/udp port accept is stored either as its own per-family + // hook rule or as one family of a dual-stack CPORTS entry; removing it may need to + // split that entry (see removeDualStackPort). + if f.dualStackPortNeedsHook(r) { + return f.removeDualStackPort(ctx, r) + } + // A FamilyAny bare tcp/udp port accept is stored in a dual-stack CPORTS entry, in a + // v4+v6 hook pair (separate concrete-family adds), or split across both; + // removeFamilyAnyPort clears it from every backing (see there). + if f.barePortAccept(r) { + return f.removeFamilyAnyPort(ctx, r) + } + // Every other shape apf's native config cannot express (see needsHook) has already + // had its hook copy cleared above and has no native entry to split, so it is done. + if f.needsHook(r) { + return nil + } + + // A native connection-limit rule maps onto conf.apf's IG_*_CLIMIT lists. + if r.ConnLimit != nil { + return f.EditConf(ctx, r, true) + } + + // Address-less accept rules (dual-stack port lists, icmp types) live in conf.apf. + if f.isConfRule(r) { + return f.EditConf(ctx, r, true) + } + + // Otherwise edit allow_hosts.rules for accepts, deny_hosts.rules for denies. + if r.Action == Accept { + return f.EditIPList(ctx, APFAllow, Accept, r, true) + } + return f.EditIPList(ctx, APFDeny, f.denyActionFor(r.Proto), r, true) +} + +// natFile returns the routing file a NAT rule belongs in: source NAT is +// applied in POSTROUTING (postroute.rules), destination NAT in PREROUTING +// (preroute.rules). +func (f *APF) natFile(r *NATRule) string { + if r.Kind.isSource() { + return APFPostroute + } + return APFPreroute +} + +// routeHook returns a hookScript bound to one of apf's routing-rule files. apf +// sources preroute.rules and postroute.rules as shell on every (re)start, exactly +// as it sources the pre-hook, so the same raw-iptables mechanism that carries +// csf's hook NAT lines carries apf's routing-file NAT lines: a NAT rule is written +// as an `iptables -t nat` command and read back the same way, reusing the iptables +// NAT marshaller/parser and its shell-safe quoting rather than a second rendering +// path. The two files stay distinct because apf applies preroute.rules at the +// PREROUTING stage and postroute.rules at the POSTROUTING stage (see natFile), so +// each is its own hookScript; only the hook's NAT methods are exercised here. +func (f *APF) routeHook(path string) *hookScript { + return newHookScript(f.rulePrefix, path, 0600, f.ipv6Enabled) +} + +// GetNATRules returns the NAT rules held in apf's preroute and postroute routing +// files, parsed through the shared hook mechanism. +func (f *APF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) { + var rules []*NATRule + for _, path := range []string{APFPreroute, APFPostroute} { + parsed, err := f.routeHook(path).getNATRules() + if err != nil { + return nil, err + } + rules = append(rules, parsed...) + } + // APF's routing files hold raw iptables nat commands, so a rule this library + // added carries the configured prefix in its -m comment tag and UnmarshalNATRule + // derives HasPrefix from it (an empty prefix writes no tag, so HasPrefix is + // false; a rule hand-added without the tag likewise reports false). + return rules, nil +} + +// AddNATRule adds a NAT rule to apf's preroute or postroute routing file. +func (f *APF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := r.validate(); err != nil { + return err + } + // A concrete-IPv6 translation cannot be kept in sync with USE_IPV6 off: apf + // sources the routing files on every (re)load but never flushes the v6 nat + // table, so the injected line re-appends each reload and outlives its own + // removal. + if !f.ipv6Enabled && r.impliedFamily() == IPv6 { + return fmt.Errorf("apf cannot manage an IPv6 nat rule with USE_IPV6 disabled: %w", ErrUnsupportedNAT) + } + changed, err := f.routeHook(f.natFile(r)).editNAT(r, false) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// InsertNATRule is unsupported: APF stores NAT in a config file it applies as a +// whole, with no explicit ordering. +func (f *APF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + return unsupportedOrdering(f.Type()) +} + +// MoveNATRule is unsupported for the same reason as InsertNATRule. +func (f *APF) MoveNATRule(ctx context.Context, zoneName string, r *NATRule, position int) error { + return unsupportedOrdering(f.Type()) +} + +// RemoveNATRule removes a NAT rule from apf's preroute or postroute routing file. +func (f *APF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := r.validate(); err != nil { + return err + } + changed, err := f.routeHook(f.natFile(r)).editNAT(r, true) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// GetDefaultPolicy is unsupported: apf has no managed default-policy control. +func (f *APF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + return nil, unsupportedPolicy(f.Type()) +} + +// SetDefaultPolicy is unsupported: apf has no managed default-policy control. +func (f *APF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + return unsupportedPolicy(f.Type()) +} + +// GetAddressSets returns the address sets carried by the apf pre-hook. +func (f *APF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + return f.hook().getAddressSets() +} + +// GetAddressSet returns a single address set by name, or an error if absent. +func (f *APF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + sets, err := f.hook().getAddressSets() + if err != nil { + return nil, err + } + for _, s := range sets { + if s.Name == name { + return s, nil + } + } + return nil, fmt.Errorf("address set %q not found", name) +} + +// AddAddressSet writes a set as ipset commands in the pre-hook; apf --restart +// (Reload) sources the hook to create the set. Re-adding a set is idempotent. +func (f *APF) AddAddressSet(ctx context.Context, set *AddressSet) error { + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + changed, err := f.hook().editAddressSet(set, false) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a +// hook rule still references the set; removing an absent set is a no-op. +func (f *APF) RemoveAddressSet(ctx context.Context, name string) error { + changed, err := f.hook().editAddressSet(&AddressSet{Name: name}, true) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// AddAddressSetEntry adds an entry to an existing set in the pre-hook. +func (f *APF) AddAddressSetEntry(ctx context.Context, name, entry string) error { + changed, err := f.hook().editAddressSetEntry(name, entry, false) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// RemoveAddressSetEntry removes an entry from an existing set in the pre-hook. +func (f *APF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + changed, err := f.hook().editAddressSetEntry(name, entry, true) + f.ConfigChanged = f.ConfigChanged || changed + return err +} + +// Backup captures the current filter and NAT rules managed by this backend. +func (f *APF) Backup(ctx context.Context, zoneName string) (*Backup, error) { + rules, err := f.GetRules(ctx, zoneName) + if err != nil { + return nil, err + } + natRules, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return nil, err + } + // Backup captures the full filter and NAT rule state plus the hook's address + // sets; Restore removes the current rules and re-adds these, so every rule read + // is preserved. + backup := &Backup{Rules: rules, NATRules: natRules} + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *APF) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + + // Remove existing rules. + existing, err := f.GetRules(ctx, zoneName) + if err != nil { + return err + } + for _, r := range existing { + if err := f.RemoveRule(ctx, zoneName, r); err != nil { + return err + } + } + existingNAT, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return err + } + for _, r := range existingNAT { + if err := f.RemoveNATRule(ctx, zoneName, r); err != nil { + return err + } + } + + // Recreate the address sets before the rules so a set-referencing rule resolves + // when apf sources the hook. The old rules are already gone, and editAddressSet + // rewrites each set's block idempotently, so cleanFirst is unnecessary. + if err := restoreBackupSets(ctx, f, backup, false); err != nil { + return err + } + + // Re-add rules from backup. + for _, r := range backup.Rules { + if err := f.addRule(ctx, zoneName, r, false); err != nil { + return err + } + } + for _, r := range backup.NATRules { + if err := f.AddNATRule(ctx, zoneName, r); err != nil { + return err + } + } + return nil +} + +// Reload restarts apf to apply config changes, but only when a mutation changed its files. +func (f *APF) Reload(ctx context.Context) error { + // apf --restart rewrites and reloads the whole ruleset, which is disruptive, so + // only restart when a mutation actually changed apf's config files. A + // successful restart consumes the flag; otherwise a second Reload with no + // intervening mutation would restart apf again for nothing. + if f.ConfigChanged { + _, err := runCommand(ctx, "/etc/apf/apf", "--restart") + if err != nil { + return err + } + f.ConfigChanged = false + } + return nil +} + +// Close releases resources held by the manager; apf holds none. +func (f *APF) Close(ctx context.Context) error { + return nil +} diff --git a/apf_linux_test.go b/apf_linux_test.go new file mode 100644 index 0000000..f251382 --- /dev/null +++ b/apf_linux_test.go @@ -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) +} diff --git a/atomicfile.go b/atomicfile.go new file mode 100644 index 0000000..15ff5bd --- /dev/null +++ b/atomicfile.go @@ -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() +} diff --git a/atomicfile_other.go b/atomicfile_other.go new file mode 100644 index 0000000..3d95714 --- /dev/null +++ b/atomicfile_other.go @@ -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 +} diff --git a/atomicfile_test.go b/atomicfile_test.go new file mode 100644 index 0000000..4bce4b1 --- /dev/null +++ b/atomicfile_test.go @@ -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) +} diff --git a/atomicfile_unix.go b/atomicfile_unix.go new file mode 100644 index 0000000..b59b038 --- /dev/null +++ b/atomicfile_unix.go @@ -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 +} diff --git a/backup.go b/backup.go new file mode 100644 index 0000000..fe38320 --- /dev/null +++ b/backup.go @@ -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) +} diff --git a/cmd/go-firewall/backup.go b/cmd/go-firewall/backup.go new file mode 100644 index 0000000..f69f03e --- /dev/null +++ b/cmd/go-firewall/backup.go @@ -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") +} diff --git a/cmd/go-firewall/cli_test.go b/cmd/go-firewall/cli_test.go new file mode 100644 index 0000000..6f896a3 --- /dev/null +++ b/cmd/go-firewall/cli_test.go @@ -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) + } +} diff --git a/cmd/go-firewall/go.mod b/cmd/go-firewall/go.mod new file mode 100644 index 0000000..d0d6f14 --- /dev/null +++ b/cmd/go-firewall/go.mod @@ -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 => ../.. diff --git a/cmd/go-firewall/go.sum b/cmd/go-firewall/go.sum new file mode 100644 index 0000000..1535da7 --- /dev/null +++ b/cmd/go-firewall/go.sum @@ -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= diff --git a/cmd/go-firewall/info.go b/cmd/go-firewall/info.go new file mode 100644 index 0000000..f8afbdf --- /dev/null +++ b/cmd/go-firewall/info.go @@ -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") +} diff --git a/cmd/go-firewall/main.go b/cmd/go-firewall/main.go new file mode 100644 index 0000000..48c64ac --- /dev/null +++ b/cmd/go-firewall/main.go @@ -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) +} diff --git a/cmd/go-firewall/nat.go b/cmd/go-firewall/nat.go new file mode 100644 index 0000000..fa8b938 --- /dev/null +++ b/cmd/go-firewall/nat.go @@ -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") +} diff --git a/cmd/go-firewall/output.go b/cmd/go-firewall/output.go new file mode 100644 index 0000000..cde132d --- /dev/null +++ b/cmd/go-firewall/output.go @@ -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)) + } +} diff --git a/cmd/go-firewall/parse.go b/cmd/go-firewall/parse.go new file mode 100644 index 0000000..0ac8777 --- /dev/null +++ b/cmd/go-firewall/parse.go @@ -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 "/" 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 /, 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 +} diff --git a/cmd/go-firewall/policy.go b/cmd/go-firewall/policy.go new file mode 100644 index 0000000..0d81c3e --- /dev/null +++ b/cmd/go-firewall/policy.go @@ -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") +} diff --git a/cmd/go-firewall/rule.go b/cmd/go-firewall/rule.go new file mode 100644 index 0000000..9c5c0da --- /dev/null +++ b/cmd/go-firewall/rule.go @@ -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 / (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 +} diff --git a/cmd/go-firewall/set.go b/cmd/go-firewall/set.go new file mode 100644 index 0000000..a0a1b8d --- /dev/null +++ b/cmd/go-firewall/set.go @@ -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") +} diff --git a/container.go b/container.go new file mode 100644 index 0000000..14937de --- /dev/null +++ b/container.go @@ -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() +} diff --git a/container_test.go b/container_test.go new file mode 100644 index 0000000..8da8e9d --- /dev/null +++ b/container_test.go @@ -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) + } +} diff --git a/csf_linux.go b/csf_linux.go new file mode 100644 index 0000000..71c648c --- /dev/null +++ b/csf_linux.go @@ -0,0 +1,1940 @@ +package firewall + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "strconv" + "strings" + "time" +) + +const ( + CSFConf = "/etc/csf/csf.conf" + CSFAllow = "/etc/csf/csf.allow" + CSFDeny = "/etc/csf/csf.deny" + // CSFRedirect holds csf's port-forwarding rules, one per line in the + // pipe-delimited form "IPx|portA|IPy|portB|proto". A destination IP (IPy) of + // "*" is a local port redirect; a concrete IPy is a forward to another host. + CSFRedirect = "/etc/csf/csf.redirect" + // CSFHook is the csf pre-hook, run after csf flushes and before it loads its + // own rules, so injected rules land at the top of the chains and are re-added + // on every reload. This library writes the iptables rules for features csf's + // native config cannot express directly into this hook. (csf sources both + // /usr/local/csf/bin/csfpre.sh and /etc/csf/csfpre.sh when present, so this + // /etc/csf hook always runs.) + CSFHook = "/etc/csf/csfpre.sh" +) + +// CSF manages a ConfigServer Security & Firewall (csf) installation, mapping +// rules onto its config files (csf.conf, csf.allow, csf.deny, csf.redirect) and +// a managed pre-hook for features csf's native config cannot express. +type CSF struct { + // rulePrefix tags rules this library creates so they can be told apart + // from foreign rules. In csf.allow/csf.deny it is prepended to the + // comment written on the line above each rule; csf.conf port-list rules + // carry no per-rule comment and so cannot carry the tag. + rulePrefix string + // ipv6Enabled mirrors csf.conf's IPV6. With it off (the shipped default) csf + // enforces no IPv6 at all: csf.pl's linefilter silently drops a csf.allow/ + // csf.deny line resolving to an IPv6 address, the TCP6_IN/UDP6_IN port lists + // go unread, and ip6tables is never flushed on (re)load — so a hook-injected + // ip6tables line would be re-appended on every reload and outlive its own + // removal. AddRule therefore rejects every concrete-IPv6 rule rather than + // write one csf will never enforce. + ipv6Enabled bool +} + +// NewCSF constructs a CSF manager, verifying the csf service is enabled and its +// config files are present, and reading whether csf's own IPv6 handling is on. +func NewCSF(ctx context.Context, rulePrefix string) (*CSF, error) { + csf := new(CSF) + csf.rulePrefix = rulePrefix + + // Confirm csf is enabled under whatever init system the host uses + // (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local). + if !serviceEnabled(ctx, "csf") { + return nil, fmt.Errorf("the csf service is not enabled on this server") + } + + // Confirm config files exist. + files := []string{CSFConf, CSFAllow, CSFDeny} + for _, f := range files { + if _, err := os.Stat(f); err != nil { + return nil, fmt.Errorf("the config file %s is missing", f) + } + } + + // Confirm it is not disabled. + if _, err := os.Stat("/etc/csf/csf.disable"); err == nil { + return nil, fmt.Errorf("csf is currently disabled") + } + + // Read whether csf's own IPv6 handling is turned on. + useIPv6, err := readConfValue(CSFConf, "IPV6") + if err != nil { + return nil, fmt.Errorf("error reading %s: %s", CSFConf, err) + } + csf.ipv6Enabled = useIPv6 == "1" + + return csf, nil +} + +// Type reports the backend identifier, "csf". +func (f *CSF) Type() string { + return CSFType +} + +// Capabilities reports the firewall features csf supports. +func (f *CSF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + // IPv6 mirrors ipv6Enabled: with csf.conf's IPV6 off, csf never touches + // ip6tables, so neither its native config nor the raw-iptables hook yields a + // rule csf will keep in sync across a reload (see ipv6Enabled). + IPv6: f.ipv6Enabled, + PortPair: true, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: false, + DefaultPolicy: false, + RuleCounters: true, + AddressSets: true, + Comments: true, + Negation: true, + RejectAction: true, + FamilyWithoutAddress: true, + // A csf.deny entry stores no action; csf applies csf.conf's configured + // deny action, so removal matches an entry whatever action is named. + DenyActionFromConfig: true, + } +} + +// GetZone reports no zone; csf has no concept of zones. +func (f *CSF) GetZone(ctx context.Context, iface string) (zoneName string, err error) { + return "", nil +} + +// ParseConnLimit decodes a csf.conf CONNLIMIT value ("port;limit,...") into +// connection-limit rules: csf caps concurrent new TCP connections per source and +// rejects the excess with a TCP reset, so each entry becomes an inbound tcp +// reject rule carrying a per-source ConnLimit. +func (f *CSF) ParseConnLimit(val string) (rules []*Rule) { + for _, entry := range strings.Split(val, ",") { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + portTok, limitTok, ok := strings.Cut(entry, ";") + if !ok { + continue + } + port, err := strconv.ParseUint(strings.TrimSpace(portTok), 10, 16) + if err != nil { + continue + } + limit, err := strconv.ParseUint(strings.TrimSpace(limitTok), 10, 32) + if err != nil { + continue + } + // CONNLIMIT is a single config key, but csf.pl only installs its IPv6 + // CONNLIMIT rule (ip6tables) when csf.conf's IPV6 is enabled (ConfigServer/ + // Config.pm, csf.pl); on the shipped default (IPV6="0") CONNLIMIT is IPv4 + // only. Report FamilyAny when IPv6 handling is on — so a FamilyAny desired + // connlimit rule reconciles with its dual-stack read-back rather than + // churning every Sync — and IPv4 otherwise, matching what csf actually + // enforces. + fam := IPv4 + if f.ipv6Enabled { + fam = FamilyAny + } + rules = append(rules, &Rule{ + Family: fam, + Proto: TCP, + Port: uint16(port), + Action: Reject, + ConnLimit: &ConnLimit{Count: uint(limit), PerSource: true}, + }) + } + return +} + +// parseAddr classifies a csf advanced-rule address field. It returns the +// address, its family, and whether the value is an address at all (a non-address +// value is a port list or ICMP type). A zero "any" network (0.0.0.0/0 or ::/0) +// is normalized to an empty address so a port-only rule written with the "any" +// placeholder round-trips against a rule that carries no address. +func (f *CSF) parseAddr(v string) (addr string, fam Family, ok bool) { + family, ok := parseAddrFamily(v) + if !ok { + return "", FamilyAny, false + } + if _, network, err := net.ParseCIDR(v); err == nil { + if ones, _ := network.Mask.Size(); ones == 0 && network.IP.IsUnspecified() { + return "", family, true + } + } + return v, family, true +} + +// parseAdvPorts parses a csf advanced-rule port value: a comma list whose +// entries are single ports or underscore ranges (e.g. "22,80,2000_3000"). +func (f *CSF) parseAdvPorts(val string) ([]PortRange, error) { + var specs []PortRange + for _, tok := range strings.Split(val, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + lo, hi, isRange := strings.Cut(tok, "_") + start, err := strconv.ParseUint(strings.TrimSpace(lo), 10, 16) + if err != nil { + return nil, 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 nil, fmt.Errorf("invalid port %q", hi) + } + pr.End = uint16(end) + if pr.End < pr.Start { + return nil, fmt.Errorf("port range end below start") + } + } + specs = append(specs, pr) + } + if len(specs) == 0 { + return nil, fmt.Errorf("no ports") + } + return specs, nil +} + +// ParseAdvRule decodes a csf advanced allow/deny rule of the form +// tcp/udp/icmp|in/out|s/d=port(s)|s/d=ip. The port field accepts a comma +// multiport list and underscore ranges; for icmp it holds the ICMP type. +func (f *CSF) ParseAdvRule(val string, action Action) (r *Rule) { + r = &Rule{ + Action: action, + } + + fields := strings.Split(val, "|") + for _, fld := range fields { + switch { + case strings.EqualFold(fld, "tcp"): + r.Proto = TCP + case strings.EqualFold(fld, "udp"): + r.Proto = UDP + case strings.EqualFold(fld, "icmp"): + r.Proto = ICMP + case strings.EqualFold(fld, "in"): + r.Direction = DirInput + case strings.EqualFold(fld, "out"): + r.Direction = DirOutput + case strings.HasPrefix(fld, "s="): + // The source field is either an address or, when it is not, an ICMP type + // for icmp rules or a source port list/range otherwise. csf reuses the + // port position for the ICMP type in both s= and d= (csf.pl maps + // `s=` to `--icmp-type ` for an icmp rule), so mirror the d= branch. + v := strings.TrimPrefix(fld, "s=") + if addr, fam, ok := f.parseAddr(v); ok { + r.Family = fam + r.Source = addr + continue + } + if r.Proto == ICMP { + n, ok := parseICMPType(v) + if !ok { + return nil + } + r.ICMPType = Ptr(n) + continue + } + specs, err := f.parseAdvPorts(v) + if err != nil { + return nil + } + sourcePortSpecsToRule(r, specs) + case strings.HasPrefix(fld, "d="): + v := strings.TrimPrefix(fld, "d=") + // A destination value is either an address, or (when it is not) an + // ICMP type for icmp rules or a port list/range otherwise. + if addr, fam, ok := f.parseAddr(v); ok { + r.Family = fam + r.Destination = addr + continue + } + if r.Proto == ICMP { + n, ok := parseICMPType(v) + if !ok { + return nil + } + r.ICMPType = Ptr(n) + continue + } + specs, err := f.parseAdvPorts(v) + if err != nil { + return nil + } + portSpecsToRule(r, specs) + case strings.HasPrefix(fld, "u=") || strings.HasPrefix(fld, "g="): + return nil + } + } + + // csf.pl defaults a protocol-less advanced line to `-p tcp` (its $protocol + // starts as "-p tcp"), so mirror it: reading the line back as ProtocolAny + // would report an all-protocol rule csf does not enforce, and one whose + // removal the iptables validity check rejects. + if r.Proto == ProtocolAny { + r.Proto = TCP + } + + return +} + +// ParseIPList reads a csf.allow/csf.deny file into rules, stamping each with the +// given action and any full-line comment that precedes it (see scanCommentGroups +// for the comment-attachment convention). +func (f *CSF) ParseIPList(filePath string, action Action) (rules []*Rule, err error) { + // Read the allow/deny IP rule list. + fd, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer func() { _ = fd.Close() }() + + err = scanCommentGroups(fd, f.rulePrefix, nil, func(g commentGroup) error { + // Strip an inline trailing comment (not a rule comment). + line := trimInlineComment(g.line) + if line == "" { + return nil + } + // parseListLine classifies an advanced line (pipe or colon delimited) or a + // plain address line — one bidirectional DirAny rule authored in the + // inbound frame (Source=X) — and skips anything else. + rule := f.parseListLine(line, action) + if rule == nil { + return nil + } + rule.Comment, rule.HasPrefix = prefixedComment(f.rulePrefix, g.comment) + rules = append(rules, rule) + return nil + }) + if err != nil { + return nil, err + } + return +} + +// ParsePorts decodes a csf.conf port-list value into one accept rule per port +// token for the given family, protocol, and direction. +func (f *CSF) ParsePorts(val string, family Family, proto Protocol, dir Direction) (rules []*Rule) { + ports := strings.Split(val, ",") + for _, port := range ports { + port = strings.TrimSpace(port) + if port == "" { + continue + } + + // A csf.conf port token is a single port or a colon range. + pr, err := ParsePortRange(port) + if err != nil { + continue + } + rule := &Rule{ + Family: family, + Proto: proto, + Direction: dir, + Action: Accept, + } + portSpecsToRule(rule, []PortRange{pr}) + rules = append(rules, rule) + } + return +} + +// dropActions reads csf.conf's DROP (inbound) and DROP_OUT (outbound) +// settings, which decide whether a csf.deny entry is dropped or rejected: csf +// builds its DENYIN chain with `-j $DROP` and its DENYOUT chain with +// `-j $DROP_OUT`, so a deny rule's effective action follows its direction. +// Only "DROP" and "REJECT" are valid values; anything else (or an unreadable +// file) falls back to stock csf defaults — DROP drops inbound, DROP_OUT rejects +// outbound. +func (f *CSF) dropActions() (dropIn, dropOut Action) { + dropIn, dropOut = Drop, Reject + fd, err := os.Open(CSFConf) + if err != nil { + return + } + defer func() { _ = fd.Close() }() + + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := scanner.Text() + if ci := strings.IndexByte(line, '#'); ci >= 0 { + line = line[:ci] + } + key, val, found := strings.Cut(strings.TrimSpace(line), "=") + if !found { + continue + } + key = strings.TrimSpace(key) + val = strings.ToUpper(trimQuotes(strings.TrimSpace(val))) + switch key { + case "DROP": + if val == "REJECT" { + dropIn = Reject + } else { + dropIn = Drop + } + case "DROP_OUT": + if val == "DROP" { + dropOut = Drop + } else { + dropOut = Reject + } + } + } + return +} + +// hook returns the managed pre-hook script used to inject iptables rules for +// features csf's native config cannot express. +func (f *CSF) hook() *hookScript { + return newHookScript(f.rulePrefix, CSFHook, 0700, f.ipv6Enabled) +} + +// --- live counters ----------------------------------------------------------- + +// liveChain reports the direction a live chain's rows stand for, and whether the +// chain is one csf's rules reach at all. csf appends its config-driven rules +// straight onto INPUT/OUTPUT/FORWARD and keeps the allow and deny lists in +// per-direction chains of its own, so every chain here names its direction. The +// rest — csf's sanity, logging and rate-limit chains — hold rows that stand for +// no rule this backend reports. +func (f *CSF) liveChain(chain string) (Direction, bool) { + switch chain { + case "INPUT", "LOCALINPUT", "ALLOWIN", "DENYIN", "GALLOWIN", "GDENYIN": + return DirInput, true + case "OUTPUT", "LOCALOUTPUT", "ALLOWOUT", "DENYOUT", "GALLOWOUT", "GDENYOUT": + return DirOutput, true + case "FORWARD", "ALLOWFWD", "DENYFWD": + return DirForward, true + } + return DirInput, false +} + +// interfaceFrame returns the interface match csf stamps on every rule it +// generates: `! -i lo`/`! -o lo` by default, or `-i `/`-o ` when +// csf.conf's ETH_DEVICE names one. The frame is csf's own — a TCP_IN entry +// decodes to a plain port accept, not an interface-bound one — so it is stripped +// off a live row before the row is parsed. Stripping it is also what separates a +// row csf generated from a raw one the pre-hook injected, which carries no frame +// and is reported exactly as written. +func (f *CSF) interfaceFrame() (in, out string) { + dev, err := readConfValue(CSFConf, "ETH_DEVICE") + if err != nil || dev == "" { + return "! -i lo", "! -o lo" + } + return "-i " + dev, "-o " + dev +} + +// logDropAction maps csf's logging drop chains onto the terminal action they end +// in. csf routes a blocked packet through LOGDROPIN/LOGDROPOUT when DROP_LOGGING +// is on, so the row that stands for a deny jumps to one of those rather than +// naming its action; the action is csf.conf's DROP (inbound) or DROP_OUT +// (outbound), which is what the deny list decodes to. Both are passed in, having +// been read once for the whole ruleset rather than per row. +func (f *CSF) logDropAction(target string, dropIn, dropOut Action) (Action, bool) { + switch target { + case "LOGDROPIN": + return dropIn, true + case "LOGDROPOUT": + return dropOut, true + } + return ActionInvalid, false +} + +// parseLiveRules decodes counter-annotated `iptables-save -c` output into the +// rules csf's chains hold. csf frames every rule it generates with an interface +// match, and a port-list rule with a NEW state match, neither of which is part of +// the rule the config decodes to; both are undone here so a row lines up with the +// entry that produced it. A row jumping into csf's logging drop chain is restored +// to the action that chain ends in. +func (f *CSF) parseLiveRules(out []string, fam Family) []*Rule { + // The settings that decide how a row reads back come from csf.conf, so read + // them once here rather than per row. + frameIn, frameOut := f.interfaceFrame() + dropIn, dropOut := f.dropActions() + return decodeLiveRows(out, func(row liveRow) (*Rule, bool) { + dir, ok := f.liveChain(row.chain) + if !ok { + return nil, false + } + + // Strip csf's interface frame. The rulespec parser rejects a negated + // interface outright, so this has to happen on the line rather than on the + // parsed rule. + framed := false + for _, frame := range []string{frameIn, frameOut} { + if strings.Contains(row.line, " "+frame+" ") { + row.line = strings.Replace(row.line, " "+frame+" ", " ", 1) + framed = true + } + } + + // Restore the action behind a jump into csf's logging drop chain. + target := jumpTarget(row.fields) + if action, isDrop := f.logDropAction(target, dropIn, dropOut); isDrop { + row.line = strings.Replace(row.line, "-j "+target, + "-j "+strings.ToUpper(action.String()), 1) + } + + rule, ok := parseLiveRow(row, dir, fam) + if !ok { + return nil, false + } + // csf opens a port-list rule with a NEW state match. Clear it only on a row + // csf framed, so a pre-hook rule that genuinely matches on state keeps it. + if framed && rule.State == StateNew { + rule.State = 0 + } + return rule, true + }) +} + +// claimDenyOutRows attributes the outbound half of a bidirectional deny. A +// csf.deny address is one line that csf implements as two rows carrying two +// different actions — csf.conf's DROP inbound, DROP_OUT outbound — while the +// line reads back as a single DirAny rule stamped with the inbound one (see +// GetRules). The outbound row therefore matches that rule on every field but +// action and is left unclaimed, so it is claimed here rather than dropped: its +// packets are the ones that entry blocked on the way out. +func (f *CSF) claimDenyOutRows(targets, leftover []*Rule) { + dropIn, dropOut := f.dropActions() + if dropIn == dropOut { + return + } + for _, l := range leftover { + if l == nil || l.Direction != DirOutput || l.Action != dropOut { + continue + } + // Compare with the action the entry reads back as; every other field must + // still line up, so an unrelated outbound deny is not absorbed. + probe := *l + probe.Action = dropIn + for _, r := range targets { + if r.Direction != DirAny || r.Action != dropIn || !r.Covers(&probe) { + continue + } + r.Packets += l.Packets + r.Bytes += l.Bytes + break + } + } +} + +// mergeLiveCounters copies the kernel's packet/byte counters onto the rules read +// from csf.conf, the allow/deny lists and the pre-hook. +func (f *CSF) mergeLiveCounters(ctx context.Context, rules []*Rule, fam Family) { + targets := countableRules(rules, fam) + if len(targets) == 0 { + return + } + leftover := applyLiveCounters(targets, f.parseLiveRules(liveSaveLines(ctx, fam), fam)) + f.claimDenyOutRows(targets, leftover) +} + +// GetRules reads all filter rules from csf's config files and the managed +// pre-hook, merging family and protocol fan-outs back to their written form. +func (f *CSF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + // Read the standard configuration. + fd, err := os.Open(CSFConf) + if err != nil { + return nil, err + } + + // Scan each line. + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + // Get the line. + line := scanner.Text() + + // Remove comments. + ci := strings.IndexByte(line, '#') + if ci >= 0 { + line = line[:ci] + } + + // Trim spaces. + line = strings.TrimSpace(line) + + // Ignore zero lines. + if len(line) == 0 { + continue + } + + // Parse key/value. + key, val, found := strings.Cut(line, "=") + if !found { + continue + } + key = strings.TrimSpace(key) + val = trimQuotes(strings.TrimSpace(val)) + + // Parse rules. + switch key { + case "TCP_IN": + rules = append(rules, f.ParsePorts(val, IPv4, TCP, DirInput)...) + case "TCP_OUT": + rules = append(rules, f.ParsePorts(val, IPv4, TCP, DirOutput)...) + case "UDP_IN": + rules = append(rules, f.ParsePorts(val, IPv4, UDP, DirInput)...) + case "UDP_OUT": + rules = append(rules, f.ParsePorts(val, IPv4, UDP, DirOutput)...) + case "TCP6_IN", "TCP6_OUT", "UDP6_IN", "UDP6_OUT": + // With IPV6 off csf never applies the *6_* lists, so their entries + // are inert; reporting them would claim IPv6 coverage the firewall + // does not enforce. + if !f.ipv6Enabled { + continue + } + proto := TCP + if strings.HasPrefix(key, "UDP") { + proto = UDP + } + dir := DirInput + if strings.HasSuffix(key, "_OUT") { + dir = DirOutput + } + rules = append(rules, f.ParsePorts(val, IPv6, proto, dir)...) + case "CONNLIMIT": + rules = append(rules, f.ParseConnLimit(val)...) + } + } + + _ = fd.Close() + if err := scanner.Err(); err != nil { + return nil, err + } + + // Read the allowed IP rule list. + ipRules, err := f.ParseIPList(CSFAllow, Accept) + if err != nil { + return nil, err + } + rules = append(rules, ipRules...) + + // Read the denied IP rule list. A csf.deny entry takes effect as the DROP + // (inbound) or DROP_OUT (outbound) action from csf.conf, so stamp each rule + // with the action its direction actually gets rather than a fixed Reject — + // otherwise a Drop rule the caller manages reads back as Reject, never + // compares equal to the desired rule, and churns on every Sync. + // LF_BLOCKINONLY (off in stock csf.conf) is not modeled: with it set, csf + // skips a deny line's outbound rule while the line still reads back DirAny. + dropIn, dropOut := f.dropActions() + ipRules, err = f.ParseIPList(CSFDeny, dropIn) + if err != nil { + return nil, err + } + for _, r := range ipRules { + if r.IsOutput() { + r.Action = dropOut + } + } + rules = append(rules, ipRules...) + + // Read the iptables rules injected through the csf pre-hook (state, + // interface, logging, rate-limit, icmpv6). + hookRules, err := f.hook().getRules() + if err != nil { + return nil, err + } + rules = append(rules, hookRules...) + + // csf's config files carry no packet/byte counters — the kernel does — so + // merge them from the live ruleset (RuleCounters). + f.mergeLiveCounters(ctx, rules, IPv4) + f.mergeLiveCounters(ctx, rules, IPv6) + return +} + +// confPortToken renders a port spec for a csf.conf port list, where a range +// is written with a colon (e.g. "30000:35000"). +func (f *CSF) confPortToken(pr PortRange) 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) +} + +// editConnLimit renders the csf.conf CONNLIMIT line with a port's per-source +// limit added or removed, preserving the other entries. +func (f *CSF) editConnLimit(val string, port uint16, limit uint, remove bool) string { + portStr := strconv.Itoa(int(port)) + var kept []string + present := false + for _, tok := range strings.Split(val, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + p, _, ok := strings.Cut(tok, ";") + if ok && strings.TrimSpace(p) == portStr { + present = true + if remove { + continue + } + kept = append(kept, fmt.Sprintf("%d;%d", port, limit)) + continue + } + kept = append(kept, tok) + } + if !remove && !present { + kept = append(kept, fmt.Sprintf("%d;%d", port, limit)) + } + return fmt.Sprintf(`CONNLIMIT = "%s"`, strings.Join(kept, ",")) +} + +// isConnLimitRule reports whether a rule maps onto csf.conf's CONNLIMIT: a +// per-source cap on concurrent new connections to a single inbound TCP port with +// no address. csf's CONNLIMIT chain rejects the excess with a TCP reset +// (`-j REJECT --reject-with tcp-reset`), so the excess action is Reject, not Drop. +func (f *CSF) isConnLimitRule(r *Rule) bool { + return r.ConnLimit != nil && r.ConnLimit.PerSource && + !r.IsOutput() && r.Proto == TCP && r.Source == "" && r.Destination == "" && + r.HasPorts() && !r.HasPortSet() && r.Action == Reject +} + +// EditRulePort returns the config line for key with the rule's port added or +// removed, leaving lines the rule does not apply to unchanged. +func (f *CSF) EditRulePort(orig, key, val string, r *Rule, remove bool) string { + // A connection-limit rule is expressed solely through the CONNLIMIT config; + // it must never also add or remove its port from an accept port list, or + // RemoveRule would close a port the caller never opened and a round-trip + // would report a spurious accept rule alongside the connlimit. + if r.ConnLimit != nil && key != "CONNLIMIT" { + return orig + } + + // Determine if this key needs edits. + switch key { + case "TCP_IN": + if r.IsOutput() || r.Family == IPv6 || r.Proto == UDP { + return orig + } + case "TCP_OUT": + if !r.IsOutput() || r.Family == IPv6 || r.Proto == UDP { + return orig + } + case "UDP_IN": + if r.IsOutput() || r.Family == IPv6 || r.Proto == TCP { + return orig + } + case "UDP_OUT": + if !r.IsOutput() || r.Family == IPv6 || r.Proto == TCP { + return orig + } + case "TCP6_IN": + if r.IsOutput() || r.Family == IPv4 || r.Proto == UDP { + return orig + } + if !remove && !f.ipv6Enabled && r.Family != IPv6 { + // With IPV6 off a family-agnostic add is written for IPv4 only (the + // v6 entry would sit inert and read back as unenforced coverage); + // removals still sweep, and a concrete-IPv6 row written by Restore + // keeps its family. The list-file analog is filterFamiliesIPv6. + return orig + } + case "TCP6_OUT": + if !r.IsOutput() || r.Family == IPv4 || r.Proto == UDP { + return orig + } + if !remove && !f.ipv6Enabled && r.Family != IPv6 { + // With IPV6 off a family-agnostic add is written for IPv4 only (the + // v6 entry would sit inert and read back as unenforced coverage); + // removals still sweep, and a concrete-IPv6 row written by Restore + // keeps its family. The list-file analog is filterFamiliesIPv6. + return orig + } + case "UDP6_IN": + if r.IsOutput() || r.Family == IPv4 || r.Proto == TCP { + return orig + } + if !remove && !f.ipv6Enabled && r.Family != IPv6 { + // With IPV6 off a family-agnostic add is written for IPv4 only (the + // v6 entry would sit inert and read back as unenforced coverage); + // removals still sweep, and a concrete-IPv6 row written by Restore + // keeps its family. The list-file analog is filterFamiliesIPv6. + return orig + } + case "UDP6_OUT": + if !r.IsOutput() || r.Family == IPv4 || r.Proto == TCP { + return orig + } + if !remove && !f.ipv6Enabled && r.Family != IPv6 { + // With IPV6 off a family-agnostic add is written for IPv4 only (the + // v6 entry would sit inert and read back as unenforced coverage); + // removals still sweep, and a concrete-IPv6 row written by Restore + // keeps its family. The list-file analog is filterFamiliesIPv6. + return orig + } + case "CONNLIMIT": + // CONNLIMIT tokens are "port;limit", edited independently of the port + // lists above. + if !f.isConnLimitRule(r) { + return orig + } + // isConnLimitRule guarantees a single discrete port, which may be + // carried in either Port or a one-element Ports; read it via PortSpecs so + // a rule expressing its port through Ports is not written as port 0. + return f.editConnLimit(val, r.PortSpecs()[0].Start, r.ConnLimit.Count, remove) + default: + return orig + } + + // The rule may carry one or more ports (a single port, a range, or a list). + // Add or remove each of the rule's port tokens from the config list, + // preserving any existing tokens the rule does not touch. + specs := r.PortSpecs() + present := make(map[string]bool) + var kept []string + for _, tok := range strings.Split(val, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + // Preserve tokens we cannot parse untouched. + pr, err := ParsePortRange(tok) + if err != nil { + kept = append(kept, tok) + continue + } + if remove && portRangeInSpecs(pr, specs) { + continue + } + kept = append(kept, tok) + present[f.confPortToken(pr)] = true + } + if !remove { + for _, sp := range specs { + tok := f.confPortToken(sp) + if !present[tok] { + kept = append(kept, tok) + present[tok] = true + } + } + } + + // Re-create the configuration with new port list. + return fmt.Sprintf(`%s = "%s"`, key, strings.Join(kept, ",")) +} + +// EditConf rewrites csf.conf to add or remove a port-list or CONNLIMIT rule. +func (f *CSF) EditConf(ctx context.Context, r *Rule, remove bool) error { + // Open the standard config file; EditRulePort rewrites the port-list and + // CONNLIMIT lines the rule applies to. + fd, err := os.Open(CSFConf) + if err != nil { + return err + } + + // Stage the rewrite, preserving csf.conf's mode and ownership. + af, err := newAtomicFile(CSFConf, 0644) + if err != nil { + _ = fd.Close() + return err + } + defer af.Abort() + + // Parse config one line at a time, adding the port rule. + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + // Get the line. + orig := scanner.Text() + line := orig + + // Remove comments. + ci := strings.IndexByte(line, '#') + if ci >= 0 { + line = line[:ci] + } + + // Trim spaces. + line = strings.TrimSpace(line) + + // Ignore zero lines. + if len(line) == 0 { + _, _ = fmt.Fprintln(af, orig) + continue + } + + // Parse key/value. + key, val, found := strings.Cut(line, "=") + if !found { + _, _ = fmt.Fprintln(af, orig) + continue + } + key = strings.TrimSpace(key) + val = trimQuotes(strings.TrimSpace(val)) + + // Parse rules. + orig = f.EditRulePort(orig, key, val, r, remove) + _, _ = fmt.Fprintln(af, orig) + } + + _ = fd.Close() + + // A read error means the rewritten file is truncated; discard it. + if serr := scanner.Err(); serr != nil { + return serr + } + + // Move new file into place, preserving mode and ownership. + return af.Commit() +} + +// advPortValue renders port specs for a csf advanced rule, which uses a comma +// list and an underscore range (e.g. "22,80,2000_3000"). +func (f *CSF) advPortValue(specs []PortRange) string { + parts := make([]string, len(specs)) + for i, pr := range specs { + pr = pr.normalized() + if pr.Start == pr.End { + parts[i] = strconv.FormatUint(uint64(pr.Start), 10) + } else { + parts[i] = fmt.Sprintf("%d_%d", pr.Start, pr.End) + } + } + return strings.Join(parts, ",") +} + +// MarshalAdvRule encodes a rule as a csf advanced allow/deny line: a protocol +// token, a direction, one port-flow field (an icmp type, a source port or a +// destination port) and one address field, joined by "|". It validates nothing; +// addRule/RemoveRule route every shape the line cannot carry elsewhere first. +func (f *CSF) MarshalAdvRule(r *Rule) string { + var parts []string + switch r.Proto { + case TCP: + parts = append(parts, "tcp") + case UDP: + parts = append(parts, "udp") + case ICMP: + parts = append(parts, "icmp") + } + if r.IsOutput() { + parts = append(parts, "out") + } else { + parts = append(parts, "in") + } + + // The port-flow field: an ICMP type, a source port, or a destination port. + switch { + case r.Proto == ICMP: + if r.ICMPType != nil { + parts = append(parts, fmt.Sprintf("d=%d", *r.ICMPType)) + } + case r.HasSourcePorts(): + parts = append(parts, "s="+f.advPortValue(r.SourcePortSpecs())) + case r.HasPorts(): + parts = append(parts, "d="+f.advPortValue(r.PortSpecs())) + } + + // Address. + if r.Source != "" { + parts = append(parts, "s="+r.Source) + } else if r.Destination != "" { + parts = append(parts, "d="+r.Destination) + } + + return strings.Join(parts, "|") +} + +// parseListLine parses one csf.allow/csf.deny rule line into the rule it holds: an +// advanced rule, or a plain address line, which is a single bidirectional DirAny +// rule matching every protocol. It returns nil for a line that is neither, which the +// caller passes through untouched. The action comes from the file (csf.allow is +// accept, csf.deny is a deny); the line encodes none of its own. +func (f *CSF) parseListLine(line string, action Action) *Rule { + if strings.Contains(line, "|") { + return f.ParseAdvRule(line, action) + } + if family, ok := parseAddrFamily(line); ok { + return &Rule{Direction: DirAny, Family: family, Source: line, Action: action} + } + // csf.pl accepts a colon-delimited advanced line, converting `:` to `|` when + // the line carries no pipe; an address (IPv6 included) was classified above, + // so the conversion never touches one. An advanced line always carries an s= + // or d= field, which keeps other unparseable colon text opaque. + if strings.Contains(line, ":") && strings.Contains(line, "=") { + return f.ParseAdvRule(strings.ReplaceAll(line, ":", "|"), action) + } + return nil +} + +// listRows returns the csf.allow/csf.deny rows a rule materializes into, in write +// order, or none for a shape the lists cannot hold. The rule must already carry the +// action its file implies (see EditIPList's match), since each row's read-back form +// is compared against lines stamped with it. +// +// csf has no both-transports line anywhere — csf.pl's linefilter silently reads a +// protocol-less advanced line as `-p tcp` — so a TCPUDP rule fans out into a tcp row +// and a udp row. A port-only deny additionally carries no address of its own, and +// csf's advanced-rule handler only emits an iptables rule for a line that has one, so +// each of its rows takes the "any" network as a placeholder address. That literal is +// family-specific, so a family-neutral rule fans out per family too rather than +// silently becoming IPv4-only — across the families csf actually enforces (see +// filterFamiliesIPv6; with csf.conf's IPV6 off that is IPv4 alone). parseAddr normalizes +// the placeholder back to an empty address, so each row reads back as the address-less +// rule it stands for. +func (f *CSF) listRows(action Action, match *Rule) []ruleLine { + hasIP := match.Source != "" || match.Destination != "" + var rows []ruleLine + switch { + case hasIP && (match.HasPorts() || match.HasSourcePorts() || match.Proto.IsICMP()): + // A port/ICMP rule with an address is an advanced rule. + for _, sub := range expandProtocols(match) { + rows = append(rows, ruleLine{line: f.MarshalAdvRule(sub), read: sub}) + } + case hasIP: + // A bare all-protocol host allow/deny: a single address matching every + // protocol. csf.allow/csf.deny hold no other portless address shape — a + // concrete-protocol host or a source+destination pair — so AddRule diverts + // those to the raw-iptables hook (shapeNeedsHook) and never reaches here with + // one. A direct caller of this exported writer that supplies such a shape gets + // a best-effort single-address write, not a guard. + addr := match.Source + if addr == "" { + addr = match.Destination + } + // The plain line is bidirectional and names its address as the source, which is + // the frame the scan reads it back in. + read := *match + read.Direction = DirAny + read.Source, read.Destination = addr, "" + rows = append(rows, ruleLine{line: addr, read: &read}) + case action != Accept && match.HasPorts(): + for _, fam := range filterFamiliesIPv6(f.ipv6Enabled, match) { + placeholder := "0.0.0.0/0" + if fam.impliedFamily() == IPv6 { + placeholder = "::/0" + } + for _, sub := range expandProtocols(fam) { + // The row reads back address-less; only the line carries the placeholder. + // shapeNeedsHook has already routed an address-less source-port match to + // the hook, so the address field being filled in here is free. + row := *sub + if row.IsOutput() { + row.Destination = placeholder + } else { + row.Source = placeholder + } + rows = append(rows, ruleLine{line: f.MarshalAdvRule(&row), read: sub}) + } + } + } + return rows +} + +// EditIPList adds or removes a rule in a csf.allow/csf.deny list, rewriting it in +// place. An add expands the rule into the rows it materializes into (see listRows), +// notes which of them the file already holds, and appends only the rest, so a rule +// that fans out across families or transports is completed rather than duplicated on +// every reconcile. A removal drops every line the target covers. +func (f *CSF) EditIPList(ctx context.Context, filePath string, action Action, r *Rule, remove bool) error { + // Read the allow/deny IP rule list. + fd, err := os.Open(filePath) + if err != nil { + return err + } + defer func() { _ = fd.Close() }() + + // Stage the rewrite, preserving the list file's mode and ownership. + af, err := newAtomicFile(filePath, 0644) + if err != nil { + return err + } + defer af.Abort() + + // csf.allow/csf.deny encode no action of their own — the file decides it + // (csf.allow is accept, csf.deny is a deny). A rule read from a file is stamped + // with that file's action, so match an incoming rule with its action coerced + // the same way: otherwise a rule added as Drop (written to csf.deny, read back + // as the deny action) could never be found and removed. + match := *r + match.Action = action + // The rows an add must end up with, and which of them the scan finds already in + // the file. A removal wants no rows: it matches the target against each line + // directly, since a line it must drop need not be one this library would write. + var rows []ruleLine + if !remove { + rows = f.listRows(action, &match) + } + present := make([]bool, len(rows)) + + // Stream the file's comment-attached groups so a removed rule takes its + // comment with it and every kept line copies through verbatim. + err = scanCommentGroups(fd, f.rulePrefix, nil, func(g commentGroup) error { + keep := func() { + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + } + // Strip an inline trailing comment for matching, but preserve the + // original line (with its inline note) when copying it through. + line := trimInlineComment(g.line) + // A line neither form parses is not a rule; pass it — and any blank or + // detached comment — through untouched. + var cur *Rule + if line != "" { + cur = f.parseListLine(line, action) + } + if cur == nil { + keep() + return nil + } + + // A removal drops every line the target covers, along with its comment. A + // TCPUDP or family-neutral target touches each of the concrete lines it was + // written as; that coverage is folded into EqualForRemoval. + if remove { + if cur.EqualForRemoval(&match, true) { + return nil + } + keep() + return nil + } + + // An add keeps every line and only notes which wanted rows the file already + // covers, so the tail writes the rest. Coverage rather than a text compare, so + // a row is satisfied by an existing line that spans it (a TCPUDP line absorbing + // a tcp row) and by one spelled differently but meaning the same. + for i := range rows { + if !present[i] && cur.EqualForDedup(rows[i].read, true) { + present[i] = true + } + } + keep() + return nil + }) + // A read error means the rewritten file is truncated; discard it. + if err != nil { + return err + } + + // Append the wanted rows the file does not already hold. A rule that fans out is + // completed row by row: when only a subset is present (the IPv4 line but not its + // IPv6 twin, from a prior single-family add or a manual edit) the missing rows + // must still be written, or that family stays open while the library reports the + // rule in force. + writeComment := func() { + if c := combineComment(f.rulePrefix, r.Comment); c != "" { + _, _ = fmt.Fprintln(af, "# "+c) + } + } + for i, row := range rows { + if present[i] { + continue + } + writeComment() + _, _ = fmt.Fprintln(af, row.line) + } + + // Move new file into place, preserving mode and ownership. + return af.Commit() +} + +// needsHook reports whether a rule must be injected through the csf pre-hook as a +// raw iptables rule because csf's native config cannot express it. It is the single +// gate between the hook path and csf's config files: everything it rejects (returns +// true) is written to the hook, everything it accepts (returns false) maps onto +// csf.conf or the csf.allow/csf.deny lists. The shared predicates (ruleNeedsHook, +// shapeNeedsHook, bareHostOneWay) stay standalone, since APF shares them and +// RemoveRule routes on ruleNeedsHook and bareHostOneWay directly. +func (f *CSF) needsHook(r *Rule) bool { + // Features csf's native config cannot express (connection state, per-rule + // interface, logging, rate limiting, forward-chain routing, icmpv6, a transport + // csf does not carry, an address set) go to the hook. + if ruleNeedsHook(r) { + return true + } + // A shape no native csf form holds — a one-way bare host, a source+destination + // pair, a concrete-protocol portless host, an advanced-line address/port-flow + // overflow, or a bare protocol match (see shapeNeedsHook) — goes to the hook. + if shapeNeedsHook(r) { + return true + } + // An address-less multi-port accept goes to the hook's `-m multiport` match: + // a csf.conf port list stores each port as an independent token that reads + // back as its own rule, so the list shape has no single native form (see + // multiPortConfAccept). An addressed multi-port rule stays native — an + // advanced line's port field is a comma list. + if f.multiPortConfAccept(r) { + return true + } + // A connection limit csf.conf's CONNLIMIT cannot express — anything but a + // per-source cap on a single address-less inbound tcp port rejecting the excess + // (isConnLimitRule) — goes to the hook's `-m connlimit` match. + if r.ConnLimit != nil && !f.isConnLimitRule(r) { + return true + } + // An ICMPv4 rule csf's advanced-rule format cannot carry goes to the hook's + // `iptables -p icmp` match, which needs neither an address nor a type. The only + // native form is exactly one address with a concrete type: an advanced line + // requires an address, its single port-flow field carries the icmp type, and + // csf.pl's linefilter reads that field by position — an address with no type + // would land the address there (`--icmp-type `), which csf fails to parse + // and drops silently. A source+destination pair overflows the single address + // field and is routed by shapeNeedsHook above; ICMPv6 never reaches this test — + // ruleNeedsHook sends it to the hook, or the IPv6 gate rejects it. + return r.Proto == ICMP && (r.ICMPType == nil || (r.Source != "") == (r.Destination != "")) +} + +// multiPortConfAccept reports whether a rule is an address-less tcp/udp accept +// carrying a discrete multi-port list — the one port shape a csf.conf port list +// cannot hold as a single rule: TCP_IN="80,443" stores independent tokens that +// read back one rule per port, so the rule would never round-trip whole. AddRule +// injects it through the hook's `-m multiport` match, which keeps the list on +// one line; a single port or one range stays a native token. A connection-limited +// rule is excluded (CONNLIMIT routing owns it), as is a source-port match, which +// is not the port-list shape and is routed by shapeNeedsHook — the exclusions let +// RemoveRule sweep the csf.conf port lists for this shape without touching tokens +// other rules own. +func (f *CSF) multiPortConfAccept(r *Rule) bool { + return onProtocolAxis(r.Proto) && r.Action == Accept && r.ConnLimit == nil && + r.Source == "" && r.Destination == "" && !r.HasSourcePorts() && + len(r.PortSpecs()) > 1 +} + +// denyAction returns the action a csf.deny entry takes in the given direction, +// following csf.conf's DROP (inbound) / DROP_OUT (outbound) settings. A deny +// rule this library writes must carry exactly this action: csf.deny encodes no +// action of its own, so a rule read back is stamped with what csf would apply, +// and a caller asking for the opposite action could never reconcile against it. +func (f *CSF) denyAction(output bool) Action { + dropIn, dropOut := f.dropActions() + if output { + return dropOut + } + return dropIn +} + +// addRule is AddRule's implementation, with the IPv6 gate optional so Restore +// can reproduce a prior snapshot's inert entries rather than be rejected by a +// gate meant to catch fresh no-op writes. +func (f *CSF) addRule(ctx context.Context, zoneName string, r *Rule, enforceIPv6Gate bool) error { + // Reject a concrete-IPv6 rule when csf's own IPv6 handling is off, ahead of every + // routing decision below: neither csf's config nor the pre-hook can carry one that + // csf will keep in sync (see ipv6Enabled). Checking here rather than past the + // hook branches also keeps a DirAny rule from writing its input half before its + // output half is rejected. + if enforceIPv6Gate && !f.ipv6Enabled && r.impliedFamily() == IPv6 { + return fmt.Errorf("csf's IPv6 handling is disabled (csf.conf IPV6 is not \"1\"): %w", ErrUnsupported) + } + + // A DirAny rule maps to a single native construct only as a bare-host plain line; + // every other DirAny shape fans out into a concrete input rule plus its swapped + // output rule, each routed independently (a half may itself need the hook). + if r.Direction == DirAny && !dirAnyPlainLine(r) { + for _, sub := range expandDirections(r) { + if err := f.addRule(ctx, zoneName, sub, enforceIPv6Gate); err != nil { + return err + } + } + return nil + } + + // csf has no both-transports construct anywhere: its port lists are a TCP list and + // a UDP list, and csf.pl's linefilter silently reads a protocol-less advanced line + // as `-p tcp` rather than as both transports. So a TCPUDP rule fans out into a tcp + // rule and a udp rule, each routed independently, and each reads back as its own + // rule. + if r.Proto == TCPUDP { + for _, sub := range expandProtocols(r) { + if err := f.addRule(ctx, zoneName, sub, enforceIPv6Gate); err != nil { + return err + } + } + return nil + } + + // Verify the rule is valid with iptables. + if err := r.validate(); err != nil { + return fmt.Errorf("%v: %w", err, ErrUnsupported) + } + + // Any shape csf's native config cannot express (a stateful/interface/logged/ + // rate-limited rule, a one-way or concrete-protocol host, a source+destination + // pair, a source-and-destination port match, an address-less source-port match, + // an address-less multi-port accept, a non-native connection limit, a non-native + // ICMPv4 rule, or a bare protocol match) is injected as a raw iptables rule + // through the csf pre-hook. See needsHook for each clause; everything past this + // gate maps onto csf's own config files. + if f.needsHook(r) { + _, err := f.hook().edit(r, false) + return err + } + // A native connection-limit rule maps onto the csf.conf CONNLIMIT list (a + // non-native one was diverted to the hook above by needsHook). + if r.ConnLimit != nil { + return f.EditConf(ctx, r, false) + } + + // A port-only accept maps to a csf.conf port list rather than csf.allow; + // listRows has no row for it, so falling through to the csf.allow edit would + // only rewrite that file byte-identically. Only a single port or one range + // reaches here — needsHook diverted a multi-port list to the hook above. + if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept { + return f.EditConf(ctx, r, false) + } + + // Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny + // entry carries no action of its own — csf applies csf.conf's action by direction + // (DROP inbound, DROP_OUT outbound) — so a deny whose action matches is written + // natively, while one that differs has no native form and is injected through the + // pre-hook instead, whose iptables rule carries the exact action. A DirAny bare-host + // deny is expanded to its two concrete directions first, since each hook line is + // one-way. + if r.Action == Accept { + err := f.EditIPList(ctx, CSFAllow, Accept, r, false) + if err != nil { + return err + } + } else { + denyAction := f.denyAction(r.IsOutput()) + if r.Action != denyAction { + for _, sub := range expandDirections(r) { + if _, err := f.hook().edit(sub, false); err != nil { + return err + } + } + return nil + } + err := f.EditIPList(ctx, CSFDeny, denyAction, r, false) + if err != nil { + return err + } + } + return nil +} + +// AddRule adds a filter rule to the appropriate csf construct: a csf.conf port +// list, an advanced rule, a bare address list, CONNLIMIT, or the pre-hook. +func (f *CSF) AddRule(ctx context.Context, zoneName string, r *Rule) error { + return f.addRule(ctx, zoneName, r, true) +} + +// InsertRule is unsupported: CSF organizes rules in config files, not an ordered list. +func (f *CSF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + return unsupportedOrdering(f.Type()) +} + +// MoveRule is unsupported for the same reason as InsertRule. +func (f *CSF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + return unsupportedOrdering(f.Type()) +} + +// removePlainHost drops the bidirectional plain csf.allow/csf.deny line backing the +// DirAny rule e, choosing the list by the rule's action. +func (f *CSF) removePlainHost(ctx context.Context, e *Rule) error { + if e.Action == Accept { + return f.EditIPList(ctx, CSFAllow, Accept, e, true) + } + return f.EditIPList(ctx, CSFDeny, f.denyAction(false), e, true) +} + +// removeBareHostOneWay removes a one-way bare-address host rule. Such a rule is +// stored either as its own hook rule or as one direction of a bidirectional plain +// csf.allow/csf.deny line (a DirAny rule). When a matching plain line exists, split +// it: drop the line and re-add the surviving opposite direction as a hook rule so +// the untargeted direction keeps its coverage. +func (f *CSF) removeBareHostOneWay(ctx context.Context, zoneName string, r *Rule) error { + existing, err := f.GetRules(ctx, zoneName) + if err != nil { + return err + } + for _, e := range existing { + if e.Direction != DirAny || !e.EqualForRemoval(r, true) { + continue + } + // The host is stored as a bidirectional plain line; drop it, then re-add the + // surviving direction as a hook rule. + if err := f.removePlainHost(ctx, e); err != nil { + return err + } + if s := splitDualRowDirection(e, r); s != nil { + // A deny plain line's outbound half was enforced with csf.conf's + // DROP_OUT action, while the DirAny row reads back with the inbound + // action; re-express the survivor with the action csf actually applied + // so the split does not silently change wire behavior. + if s.Action != Accept && s.IsOutput() { + _, s.Action = f.dropActions() + } + _, err := f.hook().edit(s, false) + return err + } + return nil + } + // Not stored as a plain line; remove the one-way hook rule. + _, err = f.hook().edit(r, true) + return err +} + +// RemoveRule removes a filter rule from whichever csf construct holds it. +func (f *CSF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + // A non-plain-line DirAny target fans out into its two concrete-direction rules, + // mirroring addRule, so each half is removed from wherever it was written. + if r.Direction == DirAny && !dirAnyPlainLine(r) { + for _, sub := range expandDirections(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // A TCPUDP target fans out into its two concrete-transport rules, mirroring + // addRule, so each is removed from whichever list or line it was written to. A + // caller removing one transport targets that transport directly and leaves the + // other in place. + if r.Proto == TCPUDP { + for _, sub := range expandProtocols(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // Validate the shape before the hook sweep below: an iptables-inexpressible + // rule (a port on ProtocolAny) exists nowhere csf can hold it, and letting it + // fail inside the hook marshal would return the bare error without the + // sentinel AddRule attaches to the same shape. + if err := r.validate(); err != nil { + return fmt.Errorf("%v: %w", err, ErrUnsupported) + } + + // Clear any hook copy of the rule first, no matter how csf stores it. A rule csf + // carries only in the hook (see needsHook) lives nowhere else, so this is its + // entire removal; a natively-expressible rule may still have a stray hook copy — + // the library's own (a deny whose action differs from csf.conf's is stored there, + // see AddRule) or one a customer added by hand for a shape csf can also express + // natively — that must be cleared before the native entry below. DirAny is expanded + // so both one-way hook lines are matched; a rule with no hook copy makes this a + // harmless no-op. + var err error + for _, sub := range expandDirections(r) { + if _, e := f.hook().edit(sub, true); e != nil { + err = e + break + } + } + // A rule csf carries only in the hook has no native entry to fall through to, so + // return once its hook copy is cleared (or on any hook error). Returning here also + // keeps such a rule out of the plain-line split scan below, which could wrongly + // split an unrelated coexisting native entry. + if ruleNeedsHook(r) || err != nil { + return err + } + // A one-way bare host rule is stored either as its own hook rule (cleared above) or + // as one direction of a bidirectional plain line; removing it may need to split the + // plain line (see removeBareHostOneWay). + if bareHostOneWay(r) { + return f.removeBareHostOneWay(ctx, zoneName, r) + } + // Every other shape csf's native config cannot express (see needsHook) has already + // had its hook copy cleared above and has no native entry to split, so it is done. + // An address-less multi-port accept is the exception: it lives in the hook now, + // but an earlier per-port add (or a manual edit) may also hold its ports as + // csf.conf tokens, so it falls through to the port-list sweep below. + if f.needsHook(r) && !f.multiPortConfAccept(r) { + return nil + } + + // A native connection-limit rule maps onto the csf.conf CONNLIMIT list. + if r.ConnLimit != nil { + return f.EditConf(ctx, r, true) + } + + // A port-only accept maps to a csf.conf port list rather than csf.allow. + if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept { + err := f.EditConf(ctx, r, true) + if err != nil { + return err + } + } + + // Edit csf.allow if accept is the action, otherwise edit csf.deny. A csf.deny entry + // carries no action of its own — csf applies csf.conf's action by direction — so the + // deny of an address is a single entry there, and it is removed whatever action the + // caller named: asking to stop denying something means the entry goes, or RemoveRule + // would report success while csf kept enforcing it. EditIPList coerces the target's + // action to the file's, so a differing-action deny still matches the line. The hook + // copy such a deny was added as (see AddRule) was already cleared above, exactly as + // the hook copy of a matching-action deny is, so both backings are swept either way. + if r.Action == Accept { + err := f.EditIPList(ctx, CSFAllow, Accept, r, true) + if err != nil { + return err + } + } else { + err := f.EditIPList(ctx, CSFDeny, f.denyAction(r.IsOutput()), r, true) + if err != nil { + return err + } + } + return nil +} + +// UnmarshalNATRule decodes a csf.redirect line into a NATRule. +func (f *CSF) UnmarshalNATRule(line string) *NATRule { + fields := strings.Split(line, "|") + if len(fields) != 5 { + return nil + } + ipx, porta, ipy, portb, proto := fields[0], fields[1], fields[2], fields[3], fields[4] + + parsePort := func(s string) (uint16, bool) { + if s == "*" || s == "" { + return 0, true + } + n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 16) + if err != nil { + return 0, false + } + return uint16(n), true + } + + r := &NATRule{Proto: GetProtocol(proto)} + if r.Proto != TCP && r.Proto != UDP { + return nil + } + if ipx != "*" && ipx != "" { + if _, ok := parseAddrFamily(ipx); !ok { + return nil + } + r.Destination = ipx + } + pa, ok := parsePort(porta) + if !ok { + return nil + } + r.Port = pa + pb, ok := parsePort(portb) + if !ok { + return nil + } + r.ToPort = pb + + if ipy == "*" || ipy == "" { + r.Kind = Redirect + if r.ToPort == 0 || r.Port == 0 { + return nil + } + } else { + fam, ok := parseAddrFamily(ipy) + if !ok { + return nil + } + r.Kind = DNAT + r.ToAddress = ipy + r.Family = fam + } + if r.Family == FamilyAny { + r.Family = r.impliedFamily() + } + return r +} + +// natNeedsHook reports whether a NAT rule has no csf.redirect form and must be +// injected as a raw nat-table command through the pre-hook instead. csf.redirect +// encodes destination NAT only, in exactly two DNAT shapes plus the local +// redirect, always tcp/udp on a single concrete port, with no source or +// interface match (see MarshalNATRule). iptables expresses all of the excess — +// source NAT, an interface binding, a port range — directly, so those shapes are +// hooked rather than rejected. +func (f *CSF) natNeedsHook(r *NATRule) bool { + if r.Kind.isSource() { + return true + } + if r.Proto != TCP && r.Proto != UDP { + return true + } + if r.HasPortSet() { + return true + } + if r.Source != "" || r.Interface != "" { + return true + } + // A csf.redirect line carries family only through its addresses, so a family + // pinned by the Family field alone (an address-less redirect) has no native + // form: it would read back family-agnostic and never reconcile. + if r.Family != FamilyAny && familyOfAddr(r.Destination) == FamilyAny && familyOfAddr(r.ToAddress) == FamilyAny { + return true + } + switch r.Kind { + case Redirect: + return r.Port == 0 + case DNAT: + // csf.pl accepts a DNAT only as a full-IP forward (both ports "*") or a + // port forward (both ports concrete); any other pairing aborts the load. + return r.Destination == "" || (r.Port == 0) != (r.ToPort == 0) + } + return true +} + +// GetNATRules reads the NAT rules from csf.redirect and the raw nat-table +// commands the pre-hook carries. +func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) { + var rules []*NATRule + fd, err := os.Open(CSFRedirect) + switch { + case err == nil: + defer func() { _ = fd.Close() }() + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := scanner.Text() + if ci := strings.IndexByte(line, '#'); ci >= 0 { + line = line[:ci] + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + if r := f.UnmarshalNATRule(line); r != nil { + rules = append(rules, r) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + case os.IsNotExist(err): + // csf.redirect is optional; a missing file simply has no native rules. + default: + return nil, err + } + // csf.redirect is CSF's own NAT config with no per-rule prefix marker, so no + // rule in it carries the configured prefix; HasPrefix stays false (mirroring + // firewalld's zones). Hook NAT lines are raw iptables commands, so a rule this + // library added there carries the prefix in its -m comment tag instead. + hookNAT, err := f.hook().getNATRules() + if err != nil { + return nil, err + } + return append(rules, hookNAT...), nil +} + +// redirectAddr renders an address for a csf.redirect field, using "*" for an +// empty (any) address. +func (f *CSF) redirectAddr(a string) string { + if a == "" { + return "*" + } + return a +} + +// redirectPort renders a single port for a csf.redirect field, using "*" for +// an unset (0) port, which csf reads as "any/unchanged". +func (f *CSF) redirectPort(p uint16) string { + if p == 0 { + return "*" + } + return strconv.FormatUint(uint64(p), 10) +} + +// MarshalNATRule encodes a NAT rule as a csf.redirect line +// ("IPx|portA|IPy|portB|proto"): a Redirect to a local port (IPy = "*") or a DNAT +// forward to another host (IPy = ToAddress). Like MarshalAdvRule it validates +// nothing and assumes a natively-expressible rule; AddNATRule/RemoveNATRule route +// every shape csf.redirect cannot hold — source NAT, a non-tcp/udp protocol, a port +// range or list, a source/interface match, an address-less family pin, and the DNAT +// pairings csf.pl aborts on — to the pre-hook first (natNeedsHook), exactly as +// addRule routes non-native filter rules before they reach MarshalAdvRule. +func (f *CSF) MarshalNATRule(r *NATRule) string { + ipx := f.redirectAddr(r.Destination) + porta := f.redirectPort(r.Port) + if r.Kind == Redirect { + // A local port redirect: IPy is "*", portB is the target local port. + return strings.Join([]string{ipx, porta, "*", f.redirectPort(r.ToPort), r.Proto.String()}, "|") + } + // A DNAT forward to another host: IPy is the translation address. + return strings.Join([]string{ipx, porta, r.ToAddress, f.redirectPort(r.ToPort), r.Proto.String()}, "|") +} + +// editRedirect adds or removes a csf.redirect line, returning without change when +// an add is a duplicate or a remove finds no match. +func (f *CSF) editRedirect(r *NATRule, remove bool) error { + line := f.MarshalNATRule(r) + + data, err := os.ReadFile(CSFRedirect) + if err != nil { + if os.IsNotExist(err) { + if remove { + return nil + } + data = nil + } else { + return err + } + } + lines := strings.Split(string(data), "\n") + // Drop the trailing empty element left by a final newline so repeated adds do + // not accumulate blank lines. + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + + out := make([]string, 0, len(lines)+1) + found := false + for _, raw := range lines { + body := raw + if ci := strings.IndexByte(body, '#'); ci >= 0 { + body = body[:ci] + } + body = strings.TrimSpace(body) + if body != "" { + // Keep the match family-aware (EqualForRemoval): a family-scoped removal + // must not drop an opposite-family twin sharing this file (mirrors the + // filter-rule and pf/nft NAT family gates). + if existing := f.UnmarshalNATRule(body); existing != nil && existing.EqualForRemoval(r) { + found = true + if remove { + continue + } + } + } + out = append(out, raw) + } + + if remove { + if !found { + return nil + } + } else { + if found { + return nil + } + out = append(out, line) + } + + // Ensure the file ends with a single trailing newline. + content := strings.Join(out, "\n") + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + return writeConfigFile(CSFRedirect, []byte(content), 0600) +} + +// AddNATRule adds a NAT rule to csf.redirect, or — for the shapes csf.redirect +// cannot hold (natNeedsHook) — as a raw nat-table command in the pre-hook. +func (f *CSF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := r.validate(); err != nil { + return err + } + // A concrete-IPv6 translation cannot be kept in sync with IPV6 off: csf + // sources the pre-hook on every (re)load but only flushes the v6 nat table + // when IPV6 is on, so an injected ip6tables line would re-append each reload + // and outlive its own removal, and a csf.redirect v6 entry is never applied + // at all. + if !f.ipv6Enabled && r.impliedFamily() == IPv6 { + return fmt.Errorf("csf cannot manage an IPv6 nat rule with IPV6 disabled: %w", ErrUnsupportedNAT) + } + if f.natNeedsHook(r) { + _, err := f.hook().editNAT(r, false) + return err + } + return f.editRedirect(r, false) +} + +// InsertNATRule is unsupported: CSF stores redirects in a config file it applies +// as a whole, with no explicit ordering. +func (f *CSF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + return unsupportedOrdering(f.Type()) +} + +// MoveNATRule is unsupported for the same reason as InsertNATRule. +func (f *CSF) MoveNATRule(ctx context.Context, zoneName string, r *NATRule, position int) error { + return unsupportedOrdering(f.Type()) +} + +// RemoveNATRule removes a NAT rule from csf.redirect and from the pre-hook. +// The hook is swept first whatever the rule's shape: a hook-only shape lives +// nowhere else, and a natively-expressible rule may still have a stray hook +// copy a customer added by hand, mirroring the filter-side RemoveRule. +func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := r.validate(); err != nil { + return err + } + if _, err := f.hook().editNAT(r, true); err != nil { + return err + } + if f.natNeedsHook(r) { + return nil + } + return f.editRedirect(r, true) +} + +// GetDefaultPolicy is unsupported: csf exposes no chain default policy. +func (f *CSF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + return nil, unsupportedPolicy(f.Type()) +} + +// SetDefaultPolicy is unsupported: csf exposes no chain default policy. +func (f *CSF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + return unsupportedPolicy(f.Type()) +} + +// GetAddressSets returns the address sets carried by the csf pre-hook. +func (f *CSF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + return f.hook().getAddressSets() +} + +// GetAddressSet returns a single address set by name, or an error if absent. +func (f *CSF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + sets, err := f.hook().getAddressSets() + if err != nil { + return nil, err + } + for _, s := range sets { + if s.Name == name { + return s, nil + } + } + return nil, fmt.Errorf("address set %q not found", name) +} + +// AddAddressSet writes a set as ipset commands in the pre-hook; csf -r (Reload) +// sources the hook to create the set. Re-adding a set is idempotent. +func (f *CSF) AddAddressSet(ctx context.Context, set *AddressSet) error { + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + _, err := f.hook().editAddressSet(set, false) + return err +} + +// RemoveAddressSet drops a set's ipset commands from the pre-hook. It fails if a +// hook rule still references the set; removing an absent set is a no-op. +func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error { + _, err := f.hook().editAddressSet(&AddressSet{Name: name}, true) + return err +} + +// AddAddressSetEntry adds an entry to an existing set in the pre-hook. +func (f *CSF) AddAddressSetEntry(ctx context.Context, name, entry string) error { + _, err := f.hook().editAddressSetEntry(name, entry, false) + return err +} + +// RemoveAddressSetEntry removes an entry from an existing set in the pre-hook. +func (f *CSF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + _, err := f.hook().editAddressSetEntry(name, entry, true) + return err +} + +// Backup captures the current filter and NAT rules managed by this backend. +func (f *CSF) Backup(ctx context.Context, zoneName string) (*Backup, error) { + rules, err := f.GetRules(ctx, zoneName) + if err != nil { + return nil, err + } + natRules, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return nil, err + } + // Backup captures the full filter and NAT rule state plus the hook's address + // sets; Restore removes the current rules and re-adds these, so every rule read + // is preserved. + backup := &Backup{Rules: rules, NATRules: natRules} + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *CSF) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + + // Remove existing rules. + existing, err := f.GetRules(ctx, zoneName) + if err != nil { + return err + } + for _, r := range existing { + if err := f.RemoveRule(ctx, zoneName, r); err != nil { + return err + } + } + existingNAT, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return err + } + for _, r := range existingNAT { + if err := f.RemoveNATRule(ctx, zoneName, r); err != nil { + return err + } + } + + // Recreate the address sets before the rules so a set-referencing rule resolves + // when csf sources the hook. The old rules are already gone, and editAddressSet + // rewrites each set's block idempotently, so cleanFirst is unnecessary. + if err := restoreBackupSets(ctx, f, backup, false); err != nil { + return err + } + + // Re-add rules from backup. + for _, r := range backup.Rules { + if err := f.addRule(ctx, zoneName, r, false); err != nil { + return err + } + } + for _, r := range backup.NATRules { + if err := f.AddNATRule(ctx, zoneName, r); err != nil { + return err + } + } + return nil +} + +// Reload restarts csf to apply config changes, retrying past csf's transient +// restart lock. +func (f *CSF) Reload(ctx context.Context) error { + // csf serializes restarts behind a lock, so a reload issued while a previous + // restart is still finishing fails transiently with "csf is being restarted, try + // again in a moment" (Resource temporarily unavailable). Wait and retry rather + // than surfacing that transient condition — the caller asked for a reload, not to + // race csf's own in-flight restart. + var err error + for attempt := 0; attempt < 20; attempt++ { + if _, err = runCommand(ctx, "csf", "-r"); err == nil { + return nil + } + if !strings.Contains(err.Error(), "being restarted") && !strings.Contains(err.Error(), "temporarily unavailable") { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(500 * time.Millisecond): + } + } + return err +} + +// Close releases any resources held by the manager; csf holds none. +func (f *CSF) Close(ctx context.Context) error { + return nil +} diff --git a/csf_linux_test.go b/csf_linux_test.go new file mode 100644 index 0000000..f8c91d7 --- /dev/null +++ b/csf_linux_test.go @@ -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=` to `--icmp-type ` 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") +} diff --git a/firewall.go b/firewall.go new file mode 100644 index 0000000..0fe41ba --- /dev/null +++ b/firewall.go @@ -0,0 +1,1991 @@ +package firewall + +import ( + "context" + "errors" + "fmt" + "net" + "sort" + "strings" +) + +// Sentinel errors a caller can match with errors.Is to tell apart a genuine +// failure from a feature the active backend cannot express. The helpers below +// (unsupportedNAT, unsupportedOrdering, ...) wrap these, and per-backend marshal +// paths use fmt.Errorf("...: %w", err) so the message stays readable while the +// sentinel is preserved for programmatic handling. +var ( + // ErrUnsupported is the common ancestor of every unsupported-feature error. + // errors.Is returns true for any of the more specific sentinels below. + ErrUnsupported = errors.New("feature unsupported by this firewall backend") + // ErrUnsupportedNAT is returned when a backend cannot express NAT. + ErrUnsupportedNAT = fmt.Errorf("%w: NAT", ErrUnsupported) + // ErrUnsupportedOrdering is returned by backends whose rule model is not + // ordered, for InsertRule/MoveRule. + ErrUnsupportedOrdering = fmt.Errorf("%w: explicit rule ordering", ErrUnsupported) + // ErrUnsupportedPolicy is returned when a backend cannot read or set a + // default policy. + ErrUnsupportedPolicy = fmt.Errorf("%w: default-policy management", ErrUnsupported) + // ErrUnsupportedSet is returned when a backend cannot manage address sets + // (ipset/nftset/tables). + ErrUnsupportedSet = fmt.Errorf("%w: address sets", ErrUnsupported) + // ErrUnsupportedLog is returned when per-rule logging cannot be expressed. + ErrUnsupportedLog = fmt.Errorf("%w: per-rule logging", ErrUnsupported) + // ErrUnsupportedRateLimit is returned when rate limiting cannot be expressed. + ErrUnsupportedRateLimit = fmt.Errorf("%w: rate limiting", ErrUnsupported) + // ErrUnsupportedConnLimit is returned when connection limiting cannot be + // expressed. + ErrUnsupportedConnLimit = fmt.Errorf("%w: connection limiting", ErrUnsupported) + // ErrUnsupportedState is returned when connection-state matching cannot be + // expressed. + ErrUnsupportedState = fmt.Errorf("%w: connection-state matching", ErrUnsupported) + // ErrUnsupportedInterface is returned when per-rule interface matching + // cannot be expressed. + ErrUnsupportedInterface = fmt.Errorf("%w: per-rule interface matching", ErrUnsupported) + // ErrUnsupportedSourcePort is returned when source-port matching cannot be + // expressed. + ErrUnsupportedSourcePort = fmt.Errorf("%w: source-port matching", ErrUnsupported) + // ErrUnsupportedForward is returned when a backend cannot express a rule in + // the forward (routing) chain. + ErrUnsupportedForward = fmt.Errorf("%w: forward-chain rules", ErrUnsupported) +) + +// unsupportedNAT is the error a backend returns from its NAT methods when its +// model cannot express network address translation. backend names the backend. +// +//nolint:unused // only the wf backend needs it, and the authoritative `unused` run is GOOS=linux. +func unsupportedNAT(backend string) error { + return fmt.Errorf("%s does not support NAT in this model: %w", backend, ErrUnsupportedNAT) +} + +// unsupportedOrdering is returned by backends that do not support explicit +// rule ordering for InsertRule or MoveRule. +func unsupportedOrdering(backend string) error { + return fmt.Errorf("%s does not support explicit rule ordering in this model: %w", backend, ErrUnsupportedOrdering) +} + +// unsupportedPolicy is returned by backends that cannot read or set a default +// policy through this model. +func unsupportedPolicy(backend string) error { + return fmt.Errorf("%s does not support default-policy management in this model: %w", backend, ErrUnsupportedPolicy) +} + +// unsupportedSet is returned by backends that cannot manage address sets +// (ipset/nftset/tables) through this model. +// +//nolint:unused // only the wf backend needs it, and the authoritative `unused` run is GOOS=linux. +func unsupportedSet(backend string) error { + return fmt.Errorf("%s does not support address sets in this model: %w", backend, ErrUnsupportedSet) +} + +// unsupportedForward is the error a backend returns when it cannot express a +// rule in the forward (routing) chain. backend names the backend. +func unsupportedForward(backend string) error { + return fmt.Errorf("%s does not support forward-chain rules in this model: %w", backend, ErrUnsupportedForward) +} + +// familyOfAddr infers the IP family of an address or CIDR string, ignoring a +// leading '!' negation. It returns FamilyAny when the family cannot be +// determined. +func familyOfAddr(addr string) Family { + fam, _ := parseAddrFamily(strings.TrimPrefix(strings.TrimSpace(addr), "!")) + return fam +} + +// parseAddrFamily parses an address (IP or CIDR) and reports its family, or false +// when the value is not a valid address. The boolean is what distinguishes it from +// familyOfAddr, which folds an unset and an unparseable address into FamilyAny: +// config-file parsers classify a line by whether it is an address at all. +func parseAddrFamily(v string) (Family, bool) { + cidrIP, _, err := net.ParseCIDR(v) + ip := net.ParseIP(v) + if err != nil && ip == nil { + return FamilyAny, false + } + if (cidrIP != nil && cidrIP.To4() == nil) || (ip != nil && ip.To4() == nil) { + return IPv6, true + } + return IPv4, true +} + +// canonAddr canonicalizes an address match-string to a stable form and reports +// whether it parsed as an IP or CIDR. It exists because backends print the same +// address differently: nft and ufw strip a /32 (or /128) host prefix and +// zero-compress IPv6, while iptables-save adds the /32 — so the literal a rule +// was written with rarely matches the literal read back. A leading "!" negation +// is preserved; a host (bare or /32,/128) normalizes to its bare canonical form; +// a network keeps its masked base and prefix. Non-IP tokens (ipset/zone names, +// MAC addresses, "any", "") do not parse and are compared verbatim by addrEqual. +func canonAddr(s string) (string, bool) { + s = strings.TrimSpace(s) + neg := "" + if strings.HasPrefix(s, "!") { + neg = "!" + s = strings.TrimSpace(s[1:]) + } + if s == "" { + return "", false + } + if ip, ipnet, err := net.ParseCIDR(s); err == nil { + if ones, bits := ipnet.Mask.Size(); ones == bits { + // A host prefix (/32 or /128) is the same address as the bare host. + return neg + ip.String(), true + } + return neg + ipnet.String(), true + } + if ip := net.ParseIP(s); ip != nil { + return neg + ip.String(), true + } + return "", false +} + +// addrEqual reports whether two address match-strings denote the same address, +// treating a bare host and its /32 (or /128) form — and differing IPv6 spellings +// — as equal. It underpins rule identity so a rule survives the round-trip +// through a backend that re-spells addresses (see canonAddr). Tokens that are not +// IPs/CIDRs fall back to exact string comparison. +func addrEqual(a, b string) bool { + if a == b { + return true + } + ca, oka := canonAddr(a) + cb, okb := canonAddr(b) + if !oka || !okb { + return false + } + return ca == cb +} + +// splitAddrNeg splits an optional leading "!" negation from an address or set +// match-string, returning whether it was negated and the bare remainder. +func splitAddrNeg(addr string) (neg bool, bare string) { + if strings.HasPrefix(addr, "!") { + return true, addr[1:] + } + return false, addr +} + +// isSetRef reports whether a Source/Destination match-string names an address set +// (an ipset, an nft named set, a pf table) rather than an IP or CIDR. A set +// reference is any non-empty token that, after an optional leading "!" negation, +// does not parse as an address or subnet. An empty string means "any" and is not a +// set reference. Backends that support address sets (Capabilities().AddressSets) +// translate such a token into their native set-match syntax; the set itself is +// family-typed, so a set-referencing rule should carry a concrete Family. +func isSetRef(addr string) bool { + _, bare := splitAddrNeg(strings.TrimSpace(addr)) + // An empty token and the literal "any" both mean the address wildcard, not a + // named set; canonAddr cannot parse "any", so guard it explicitly. + if bare == "" || bare == "any" { + return false + } + _, ok := canonAddr(addr) + return !ok +} + +// setRefFamilyFrom resolves the single concrete family of the address set(s) a +// rule references, using lookup to read a named set's family from wherever the +// backend keeps its sets (the live kernel, its own config file, a D-Bus query). +// A named set is family-typed, so this is what pins a family-agnostic +// set-referencing rule to the one family it could ever match. An optional +// leading "!" negation and "@" set marker are stripped before lookup. A set the +// lookup cannot find, or a source/destination pair naming sets of different +// families, cannot produce a loadable rule, so both are errors. With no set +// reference among the arguments the result is IPv4; callers guard on isSetRef, +// so that arm is only a safe default. +func setRefFamilyFrom(lookup func(name string) (Family, bool, error), source, destination string) (Family, error) { + fam := FamilyAny + for _, ref := range []string{source, destination} { + if !isSetRef(ref) { + continue + } + _, bare := splitAddrNeg(strings.TrimSpace(ref)) + name := strings.TrimPrefix(bare, "@") + sf, found, err := lookup(name) + if err != nil { + return FamilyAny, err + } + if !found { + return FamilyAny, fmt.Errorf("rule references unknown address set %q", name) + } + // A family-untyped set (hash:mac and friends) matches as IPv4. + if sf != IPv6 { + sf = IPv4 + } + if fam != FamilyAny && sf != fam { + return FamilyAny, fmt.Errorf("rule references address sets of different families") + } + fam = sf + } + if fam == FamilyAny { + fam = IPv4 + } + return fam, nil +} + +// resolveSetRefRule returns r pinned to its referenced set's family when the +// rule is family-agnostic and names a set — resolve supplies the family from the +// backend's set store — and passes every other rule through unchanged. Callers +// resolve before fanning out per family, so a set-referencing rule is never +// written for a family its single-family set can never match. +func resolveSetRefRule(r *Rule, resolve func(source, destination string) (Family, error)) (*Rule, error) { + if r.impliedFamily() != FamilyAny || (!isSetRef(r.Source) && !isSetRef(r.Destination)) { + return r, nil + } + fam, err := resolve(r.Source, r.Destination) + if err != nil { + return nil, err + } + rc := *r + rc.Family = fam + return &rc, nil +} + +// resolveSetRefNAT is resolveSetRefRule for NAT rules. +func resolveSetRefNAT(r *NATRule, resolve func(source, destination string) (Family, error)) (*NATRule, error) { + if r.impliedFamily() != FamilyAny || (!isSetRef(r.Source) && !isSetRef(r.Destination)) { + return r, nil + } + fam, err := resolve(r.Source, r.Destination) + if err != nil { + return nil, err + } + rc := *r + rc.Family = fam + return &rc, nil +} + +// Rule is a firewall filter rule: a packet match and the action applied to +// matching packets. Its identity is the match fields and action; the derived +// fields (HasPrefix, Number, Packets, Bytes, Comment) describe how the backend +// stores or reports it and do not affect equality. +type Rule struct { + // Direction is the traffic direction the rule applies to: DirInput, + // DirOutput, DirForward or DirAny. + Direction Direction + // Priority orders this rule relative to others on backends that advertise + // Capabilities().Priority. + Priority int + // Family is the IP family the rule targets. FamilyAny targets both and is + // resolved from an address or ICMP protocol when left unset. + Family Family + // Source matches the packet's source address or CIDR. A leading "!" negates + // the match, and a non-IP token names an address set where supported. Empty + // matches any source. + Source string + // Destination matches the packet's destination address or CIDR, with the + // same semantics as Source. Empty matches any destination. + Destination string + // Port is the single destination port to match; Ports takes precedence when + // non-empty. + Port uint16 + // Ports is a list of destination port ranges to match. + Ports []PortRange + // SourcePort is the single source port to match; SourcePorts takes + // precedence when non-empty. + SourcePort uint16 + // SourcePorts is a list of source port ranges to match. + SourcePorts []PortRange + // Proto is the network protocol the rule matches. ProtocolAny matches every + // protocol. + Proto Protocol + // ICMPType, when set, restricts an ICMP/ICMPv6 rule to a single message + // type. A nil pointer matches every type. It is only meaningful when Proto + // is ICMP or ICMPv6. + ICMPType *uint8 + // State restricts the rule to the given connection-tracking states. The + // zero value applies no state match. + State ConnState + // InInterface matches the inbound interface the packet arrived on. Empty + // matches any interface. + InInterface string + // OutInterface matches the outbound interface the packet leaves on. Empty + // matches any interface. + OutInterface string + // Action is the action applied to matching packets. + Action Action + // Log, when set, logs each matched packet before the Action is applied. + Log bool + // LogPrefix is an optional label attached to the log line when Log is set. + LogPrefix string + // RateLimit caps the packet rate the rule matches; nil applies no limit. + RateLimit *RateLimit + // ConnLimit caps the concurrent connections the rule matches; nil applies + // no limit. + ConnLimit *ConnLimit + // Packets is the per-rule packet counter, populated by GetRules on backends + // that advertise Capabilities().RuleCounters. + Packets uint64 + // Bytes is the per-rule byte counter, populated alongside Packets. + Bytes uint64 + // Comment is an optional human-readable label stored alongside the rule on + // backends that advertise Capabilities().Comments. + Comment string + // HasPrefix reports whether the rule carries the library's configured + // prefix. It is derived on read and purely informational — the library + // itself never branches on it. + HasPrefix bool + // Number is the rule's 1-based position within its chain, populated by + // GetRules on backends that advertise Capabilities().RuleOrdering. It + // mirrors the position argument of InsertRule and MoveRule. + Number int + // table records the backend container a container backend read this rule + // from; it backs HasPrefix for the container backends. + table string + // meterSet records the dynamic set a per-source connection-limit row counts + // in, captured on read by the nftables backend so a removal can clear it. + meterSet string +} + +// IsInput reports whether the rule's direction is exactly input (inbound). It is +// a strict, single-direction test: a DirAny rule is not an input rule. Backends +// use it to route a rule to the input chain and to stamp read-back values, so it +// must not fire for DirAny; a DirAny rule reaches a per-chain path only after +// expandDirections has already split it into concrete rows. The both-directions +// coverage a DirAny rule spans is decided by coversDirection, not here. +func (r *Rule) IsInput() bool { return r.Direction == DirInput } + +// IsOutput reports whether the rule's direction is exactly output (outbound). It +// is the strict output analog of IsInput; see that method for why a DirAny rule +// must not report true here. +func (r *Rule) IsOutput() bool { return r.Direction == DirOutput } + +// IsForward reports whether the rule is a forward (routing) rule. +func (r *Rule) IsForward() bool { return r.Direction == DirForward } + +// portSpecsFor normalizes a (Port, Ports) pair into a list of port ranges: +// Ports when set, otherwise the single Port, otherwise nil. +func portSpecsFor(port uint16, ports []PortRange) []PortRange { + if len(ports) > 0 { + out := make([]PortRange, len(ports)) + for i, pr := range ports { + out[i] = pr.normalized() + } + return out + } + if port != 0 { + return []PortRange{{Start: port, End: port}} + } + return nil +} + +// portSpecsToRule is the inverse of portSpecsFor: it writes a parsed set of port +// ranges onto a rule's destination-port fields, using the single Port field for +// one discrete port and the Ports slice otherwise. +func portSpecsToRule(r *Rule, specs []PortRange) { + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.Port = specs[0].Start + return + } + r.Ports = specs +} + +// sourcePortSpecsToRule writes a parsed set of port ranges onto a rule's +// source-port fields, mirroring portSpecsToRule for the source side. +func sourcePortSpecsToRule(r *Rule, specs []PortRange) { + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.SourcePort = specs[0].Start + return + } + r.SourcePorts = specs +} + +// natPortSpecsToRule is portSpecsToRule for NAT rules: it writes a parsed set +// of match-port ranges onto the rule's Port/Ports fields. +func natPortSpecsToRule(r *NATRule, specs []PortRange) { + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.Port = specs[0].Start + return + } + r.Ports = specs +} + +// PortSpecs returns the rule's destination ports as a normalized list of +// ranges: Ports when set, otherwise the single Port, otherwise nil. +func (r *Rule) PortSpecs() []PortRange { + return portSpecsFor(r.Port, r.Ports) +} + +// SourcePortSpecs returns the rule's source ports as a normalized list of +// ranges: SourcePorts when set, otherwise the single SourcePort, otherwise nil. +func (r *Rule) SourcePortSpecs() []PortRange { + return portSpecsFor(r.SourcePort, r.SourcePorts) +} + +// HasPorts reports whether the rule matches on any destination port. +func (r *Rule) HasPorts() bool { + return r.Port != 0 || len(r.Ports) > 0 +} + +// HasSourcePorts reports whether the rule matches on any source port. +func (r *Rule) HasSourcePorts() bool { + return r.SourcePort != 0 || len(r.SourcePorts) > 0 +} + +// perSourceLimited reports whether the rule carries a per-source connection +// limit, the form whose count is keyed on the source address. +func (r *Rule) perSourceLimited() bool { + return r.ConnLimit != nil && r.ConnLimit.PerSource +} + +// HasPortSet reports whether the rule matches more than a single discrete port +// (a list, or a range spanning more than one port). Backends limited to a single +// port use this to reject rules they cannot represent. +func (r *Rule) HasPortSet() bool { + specs := r.PortSpecs() + if len(specs) > 1 { + return true + } + if len(specs) == 1 && specs[0].Start != specs[0].End { + return true + } + return false +} + +// HasSourcePortSet reports whether the rule matches more than a single discrete +// source port. +func (r *Rule) HasSourcePortSet() bool { + specs := r.SourcePortSpecs() + if len(specs) > 1 { + return true + } + if len(specs) == 1 && specs[0].Start != specs[0].End { + return true + } + return false +} + +// portNeedsConcreteProtocol reports whether a (Port, Ports) pair specifies a +// port without a concrete port-carrying protocol (tcp/udp/sctp). +func portNeedsConcreteProtocol(port uint16, ports []PortRange, proto Protocol) bool { + return (port != 0 || len(ports) > 0) && !proto.HasPorts() +} + +// PortNeedsConcreteProtocol reports whether the rule specifies a destination or +// source port without a concrete port-carrying protocol (TCP/UDP/SCTP). Most +// firewall backends cannot express a port match without such a protocol, so they +// use this to reject such a rule rather than silently widening it (matching every +// protocol) or emitting an invalid rule. A ported `any` in a ufw tuple is not this +// shape: it means tcp+udp and is modeled as TCPUDP. +func (r *Rule) PortNeedsConcreteProtocol() bool { + return portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) || portNeedsConcreteProtocol(r.SourcePort, r.SourcePorts, r.Proto) +} + +// impliedFamily returns the family a rule effectively targets, resolving +// FamilyAny from an ICMP protocol (ICMP => IPv4, ICMPv6 => IPv6) or, failing +// that, from a concrete source/destination address. A rule that names an IPv4 +// address is an IPv4 rule even when its Family was left unset, so inferring it +// keeps the rule out of the wrong-family save file (an IPv4 address in an +// ip6tables ruleset is rejected on load). +func (r *Rule) impliedFamily() Family { + if r.Family != FamilyAny { + return r.Family + } + switch r.Proto { + case ICMP: + return IPv4 + case ICMPv6: + return IPv6 + } + for _, a := range []string{r.Source, r.Destination} { + if fam := familyOfAddr(a); fam != FamilyAny { + return fam + } + } + return FamilyAny +} + +// directionSwapped returns a copy of r with its source and destination roles +// swapped: the transform between a rule's inbound and outbound materialization. +// A rule that matches inbound traffic from a host (Source=X, dport=P, in-iface) +// matches the same flow outbound as traffic to that host (Destination=X, sport=P, +// out-iface), so the source/destination address, the source/destination ports and +// the in/out interface all swap sides. Everything protocol- or policy-bound (Proto, +// ICMPType, State, Action, Log, rate/conn limits, Family, Priority, Comment, +// counters, Number, table) is direction-independent and is left untouched. It backs +// the DirAny write-side fan-out (expandDirections), the direction split on removal, +// and the inbound-frame comparison rule identity uses. The Ports/SourcePorts slice +// headers are swapped, not their elements; callers do not mutate them, matching +// splitDualRow's shallow-copy style. +func (r *Rule) directionSwapped() *Rule { + s := *r + s.Source, s.Destination = r.Destination, r.Source + s.Port, s.SourcePort = r.SourcePort, r.Port + s.Ports, s.SourcePorts = r.SourcePorts, r.Ports + s.InInterface, s.OutInterface = r.OutInterface, r.InInterface + return &s +} + +// canonicalMatch expresses a rule's match fields in the inbound (input-chain) +// frame so two rules stored in opposite directions can be compared like-for-like. +// An output rule is role-swapped into the inbound frame; input, forward and DirAny +// rules are already inbound-framed (a DirAny rule is authored inbound). It does not +// change Direction — direction coverage is decided separately by coversDirection. +func (r *Rule) canonicalMatch() *Rule { + if r.Direction == DirOutput { + return r.directionSwapped() + } + return r +} + +// portRangeInSpecs reports whether pr equals any range in specs (normalized). +func portRangeInSpecs(pr PortRange, specs []PortRange) bool { + pr = pr.normalized() + for _, sp := range specs { + if sp.normalized() == pr { + return true + } + } + return false +} + +// coalescePortRanges normalizes a port-range list to its minimal canonical form: +// each range is normalized, the list is sorted, and overlapping or directly +// contiguous ranges are merged. It exists because several backends re-spell a +// port set on read — nft in particular lists an anonymous set back with adjacent +// and overlapping ranges merged, so "{22,23,24-30}" comes back as "{22-30}". Rule +// identity compares port sets through this canonical form (see portRangesEqual) +// so such a rule still matches its own read-back and Sync does not churn. +func coalescePortRanges(prs []PortRange) []PortRange { + if len(prs) == 0 { + return nil + } + cp := make([]PortRange, len(prs)) + for i, pr := range prs { + cp[i] = pr.normalized() + } + sort.Slice(cp, func(i, j int) bool { + if cp[i].Start != cp[j].Start { + return cp[i].Start < cp[j].Start + } + return cp[i].End < cp[j].End + }) + out := []PortRange{cp[0]} + for _, pr := range cp[1:] { + last := &out[len(out)-1] + // Merge when the next range overlaps the current one or begins exactly one + // past its end (a contiguous span). The End<65535 guard avoids a uint16 + // wrap when the current range already reaches the maximum port. + if pr.Start <= last.End || (last.End < 65535 && pr.Start == last.End+1) { + if pr.End > last.End { + last.End = pr.End + } + continue + } + out = append(out, pr) + } + return out +} + +// portRangesEqual compares two port-range lists as sets, treating overlapping or +// contiguous ranges that cover the same ports as equal (see coalescePortRanges). +func portRangesEqual(a, b []PortRange) bool { + ac := coalescePortRanges(a) + bc := coalescePortRanges(b) + if len(ac) != len(bc) { + return false + } + for i := range ac { + if ac[i] != bc[i] { + return false + } + } + return true +} + +// eqU8Ptr reports whether two optional uint8 values are equal, treating nil as a +// distinct "unset" value. +func eqU8Ptr(a, b *uint8) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +// matchFields reports whether the non-family match fields of two rules are +// equal. It is the shared core of Equal and EqualBase. outputSupported is false +// on a backend with no output concept (Capabilities().Output), where the +// input/output distinction is dropped so those two directions never tell two +// rules apart. The coverage callers (covers, EqualForRemoval) pass false here +// only after coversDirection has already decided direction — including keeping a +// forward rule distinct — so the exact-direction gate is theirs to skip. +func (r *Rule) matchFields(rule *Rule, outputSupported bool) bool { + if r.Direction != rule.Direction && outputSupported { + return false + } + // Priority orders a rule relative to the others (firewalld rich rules), so two + // rules that differ only in priority are distinct — otherwise a reconcile could + // never change a rule's priority. Backends without per-rule priority leave it 0, + // so this never affects them. + if r.Priority != rule.Priority { + return false + } + if !addrEqual(r.Source, rule.Source) || !addrEqual(r.Destination, rule.Destination) { + return false + } + if !portRangesEqual(r.PortSpecs(), rule.PortSpecs()) { + return false + } + if !portRangesEqual(r.SourcePortSpecs(), rule.SourcePortSpecs()) { + return false + } + if r.Proto != rule.Proto { + return false + } + if !eqU8Ptr(r.ICMPType, rule.ICMPType) { + return false + } + if r.State != rule.State { + return false + } + if r.InInterface != rule.InInterface || r.OutInterface != rule.OutInterface { + return false + } + if r.Action != rule.Action { + return false + } + // Logging and rate/connection limits change the rule's effect, so two rules + // that differ only in these are distinct (they are not deduplicated, and a + // removal must name the same modifiers it was added with). + if r.Log != rule.Log || r.LogPrefix != rule.LogPrefix { + return false + } + if !eqRateLimit(r.RateLimit, rule.RateLimit) { + return false + } + if !eqConnLimit(r.ConnLimit, rule.ConnLimit) { + return false + } + return true +} + +// Equal reports whether two rules are the same. Family is compared through +// impliedFamily so a FamilyAny rule matches the concrete family its own content +// forces (an ICMP rule is IPv4, an ICMPv6 rule IPv6, an addressed rule its +// address's family). outputSupported is false on a backend with no output concept +// (Capabilities().Output), where direction does not distinguish two rules. +func (r *Rule) Equal(rule *Rule, outputSupported bool) bool { + if r.impliedFamily() != rule.impliedFamily() { + return false + } + return r.matchFields(rule, outputSupported) +} + +// EqualBase reports whether two rules are the same, ignoring family. +// outputSupported has Equal's meaning. +func (r *Rule) EqualBase(rule *Rule, outputSupported bool) bool { + return r.matchFields(rule, outputSupported) +} + +// coversDirection reports whether an existing rule's direction (have) already +// covers a caller rule's direction (want) — the asymmetric add/dedup form. DirAny +// spans input and output, so it covers either; it never covers DirForward (a +// routed rule has no in/out twin). When outputSupported is false the backend has no +// output concept, so input and output never distinguish two rules — but a +// forward rule is still its own chain and only covered by another forward rule. +func coversDirection(have, want Direction, outputSupported bool) bool { + if !outputSupported { + return (have == DirForward) == (want == DirForward) + } + if have == want { + return true + } + return have == DirAny && (want == DirInput || want == DirOutput) +} + +// coversDirectionRemoval reports whether two rules touch a common direction (the +// symmetric remove/move form): a DirAny on either side spans input and output, so +// it touches any concrete direction and vice versa. DirForward stands alone. +func coversDirectionRemoval(a, b Direction, outputSupported bool) bool { + if !outputSupported { + return (a == DirForward) == (b == DirForward) + } + if a == b { + return true + } + if a == DirAny && (b == DirInput || b == DirOutput) { + return true + } + if b == DirAny && (a == DirInput || a == DirOutput) { + return true + } + return false +} + +// EqualForDedup reports whether the receiver (an existing rule) already makes o +// redundant on add: the same base rule, with the receiver's family, direction +// and transport (a TCPUDP row covers its tcp/udp halves) all covering o's. It is the family- and direction-aware add guard the container +// backends need because EqualBase ignores Family — without the coverage check, +// adding an IPv6 rule whose IPv4 twin already exists would be dropped as a false +// duplicate, leaving that family unprotected. Family and direction are checked +// first so a non-covering row skips the field compare. Coverage: a FamilyAny +// receiver covers either family, a DirAny receiver covers either direction; a +// concrete value covers only its own. The match fields are compared in the inbound +// frame (canonicalMatch) so a DirAny row and a concrete DirOutput target line up, +// with direction excluded from the field compare since coversDirection already +// gated it. +func (r *Rule) EqualForDedup(o *Rule, outputSupported bool) bool { + return r.covers(o, outputSupported) +} + +// coversFamily reports whether an existing rule's family (have) already covers a caller +// rule's family want. FamilyAny spans both IP families, so it covers either; a +// concrete family covers only itself. Both sides are implied families, so an +// address- or ICMP-pinned rule is compared by the family it actually targets. +func coversFamily(have, want Family) bool { + return have == FamilyAny || have == want +} + +// covers is the coverage relation behind Covers and EqualForDedup: r's match is the +// same as o's in every ordinary field, and r's family, transport, direction and +// port axes each span o's. outputSupported is false on a backend with no output +// concept, where direction never distinguishes two rules. +func (r *Rule) covers(o *Rule, outputSupported bool) bool { + if !coversFamily(r.impliedFamily(), o.impliedFamily()) { + return false + } + if !coversDirection(r.Direction, o.Direction, outputSupported) { + return false + } + if !coversProtocol(r.Proto, o.Proto) { + return false + } + // The port axes compare in the inbound frame, as matchFields does below: an + // output rule's source and destination ports swap roles. + rc, oc := r.canonicalMatch(), o.canonicalMatch() + if !coversPorts(rc.PortSpecs(), oc.PortSpecs()) || !coversPorts(rc.SourcePortSpecs(), oc.SourcePortSpecs()) { + return false + } + // Protocol and ports are gated above, so neutralize them on their axes rather + // than let matchFields re-test them exactly — a TCPUDP row must absorb a + // concrete TCP add, and a port-list row its single-port cells. + return rc.protoNeutralized().portsNeutralized().EqualBase(oc.protoNeutralized().portsNeutralized(), false) +} + +// Covers reports whether the receiver's coverage contains o's: the same match in +// every ordinary field, with the receiver's family, transport, direction and port +// axes each spanning o's. FamilyAny spans both IP families, TCPUDP spans TCP and +// UDP, DirAny spans input and output, and a port list or range spans the ports it +// contains; a concrete value spans only itself. ProtocolAny is +// not a multi-state value — it matches every IP protocol — so it covers only +// ProtocolAny. +// +// It is the exported form of the coverage relation the library reasons with. A caller +// holding a rule read back from GetRules uses it to tell whether that rule already +// contains one it is about to add, rather than re-deriving the per-axis rules. It is +// asymmetric: a TCPUDP rule covers its TCP half, never the reverse. Direction is +// always honored; a backend that has no output concept reports Capabilities().Output +// false and folds a DirAny rule to its input half on write. +func (r *Rule) Covers(o *Rule) bool { + return r.covers(o, true) +} + +// EqualForRemoval reports whether the receiver (an existing row) should be acted +// on when the caller targets o: the same base rule, and o's family, transport and +// direction each touch the row's. It is the overlap relation a removal walks the +// stored rows with: a target removes every row it covers, and also matches a row +// that covers more than the target, which the backend then deletes and re-adds +// minus the targeted cell (splitMergedRow). A FamilyAny/TCPUDP/DirAny target +// matches every row on that axis, such a row matches any target, and otherwise +// the values must match so acting on one twin never disturbs the other. The +// field compare runs in the inbound frame (canonicalMatch) with direction +// excluded, since the axis gates above already decided it. +func (r *Rule) EqualForRemoval(o *Rule, outputSupported bool) bool { + ft, fr := o.impliedFamily(), r.impliedFamily() + if ft != FamilyAny && fr != FamilyAny && ft != fr { + return false + } + if !coversDirectionRemoval(r.Direction, o.Direction, outputSupported) { + return false + } + if !coversProtocolRemoval(r.Proto, o.Proto) { + return false + } + // Protocol is gated above; neutralize it on the tcp/udp axis so a TCPUDP row + // matches a concrete-transport target (the caller then splits it) and a TCPUDP + // target matches each concrete row it covers. + return r.canonicalMatch().protoNeutralized().EqualBase(o.canonicalMatch().protoNeutralized(), false) +} + +// oppositeFamily returns the other concrete IP family: IPv4 for IPv6 and vice +// versa. FamilyAny has no opposite and returns FamilyAny. It supports the +// dual-row split on removal, where deleting a single dual-family row to satisfy a +// concrete-family target must re-add the family the caller did not target. +func oppositeFamily(f Family) Family { + switch f { + case IPv4: + return IPv6 + case IPv6: + return IPv4 + default: + return FamilyAny + } +} + +// splitDualRow returns the rule a backend must re-add after deleting a genuine +// dual-family row — a single stored object with no family pin that covers both +// families — to satisfy a concrete-family removal: a copy of the stored row +// pinned to the family the caller did NOT target, so that family's coverage +// survives the delete. It returns nil when no split applies: the target is +// family-agnostic (the whole rule is meant to go), or the matched row is itself +// concrete-family (it removes only its own family and never a twin). Backends +// whose model cannot express the surviving single-family rule reject the removal +// with ErrUnsupported instead of calling this. +func splitDualRow(matched, target *Rule) *Rule { + tf := target.impliedFamily() + if tf == FamilyAny || matched.impliedFamily() != FamilyAny { + return nil + } + opp := *matched + opp.Family = oppositeFamily(tf) + return &opp +} + +// expandDirections returns the concrete-direction rows a rule materializes into on +// write: itself for a concrete direction, or an inbound (DirInput) row plus its +// role-swapped outbound (DirOutput) twin for a DirAny rule. Backends call it before +// their existing per-family fan-out so per-chain marshalling never has to reason +// about DirAny. The returned rows are copies; the caller's rule is untouched. +func expandDirections(r *Rule) []*Rule { + if r.Direction != DirAny { + return []*Rule{r} + } + in := *r + in.Direction = DirInput + out := r.directionSwapped() + out.Direction = DirOutput + return []*Rule{&in, out} +} + +// expandProtocols returns the concrete-transport rows a rule materializes into on +// write: itself for a single protocol, or a TCP row plus a UDP row for a TCPUDP +// rule. Backends whose native config has no both-transports form call it before +// marshalling so per-row emission never has to reason about TCPUDP. The returned +// rows are copies; the caller's rule is untouched. It is the protocol analog of +// expandDirections. +func expandProtocols(r *Rule) []*Rule { + if r.Proto != TCPUDP { + return []*Rule{r} + } + tcp, udp := *r, *r + tcp.Proto, udp.Proto = TCP, UDP + return []*Rule{&tcp, &udp} +} + +// expandFamilies returns the concrete-family rows a rule materializes into: itself +// when it already targets one family, or an IPv4 row plus an IPv6 row when it targets +// both. It reads the implied family, so a rule pinned by an address or an ICMP +// protocol is never split. Backends fan families out in their own way (a save file +// per family, a family-less inet row, a dual-stack config list), so this backs the +// coverage math in cells/CoveredBy, the filterFamiliesIPv6 narrowing, and the +// per-family fan-outs that work on rows (nftables sets, the csf/apf hook's +// per-family command lines). +func expandFamilies(r *Rule) []*Rule { + if r.impliedFamily() != FamilyAny { + return []*Rule{r} + } + v4, v6 := *r, *r + v4.Family, v6.Family = IPv4, IPv6 + return []*Rule{&v4, &v6} +} + +// filterFamiliesIPv6 returns the expandFamilies rows narrowed to the families the +// backend enforces: with ipv6Enabled false, the IPv6 row of a family-agnostic rule +// is dropped rather than written as a line the backend would never enforce. A row +// already pinned to a concrete family keeps it, IPv6 included, so Restore can +// reproduce a snapshot verbatim. +func filterFamiliesIPv6(ipv6Enabled bool, r *Rule) []*Rule { + rows := expandFamilies(r) + if ipv6Enabled || len(rows) == 1 { + return rows + } + kept := make([]*Rule, 0, 1) + for _, row := range rows { + if row.Family != IPv6 { + kept = append(kept, row) + } + } + return kept +} + +// expandPorts returns the single-port-spec rows a port-list rule materializes +// into on write: itself when each port axis carries at most one spec, otherwise +// the cross product of one row per destination-port spec and source-port spec. +// Backends whose rule form carries a single port element (a firewalld rich rule +// or zone port, a pf row — pfctl expands a list on load) call it before +// marshalling so per-row emission never has to reason about a list; the stored +// rows cover the list rule through coversPorts. The returned rows are copies; +// the caller's rule is untouched. It is the port analog of expandProtocols. +func expandPorts(r *Rule) []*Rule { + dest, src := r.PortSpecs(), r.SourcePortSpecs() + if len(dest) <= 1 && len(src) <= 1 { + return []*Rule{r} + } + // An unset axis stays unset in every row; a set axis contributes one row per + // spec, written back through portSpecsToRule so a single discrete port takes + // the Port field, matching how a backend spells a parsed row. + var dests, srcs [][]PortRange + for _, pr := range dest { + dests = append(dests, []PortRange{pr}) + } + if len(dests) == 0 { + dests = [][]PortRange{nil} + } + for _, pr := range src { + srcs = append(srcs, []PortRange{pr}) + } + if len(srcs) == 0 { + srcs = [][]PortRange{nil} + } + var out []*Rule + for _, d := range dests { + for _, s := range srcs { + c := *r + c.Port, c.Ports, c.SourcePort, c.SourcePorts = 0, nil, 0, nil + portSpecsToRule(&c, d) + sourcePortSpecsToRule(&c, s) + out = append(out, &c) + } + } + return out +} + +// expandNATPorts is expandPorts for NAT rules: it fans a match-port list into +// one rule per spec so a backend whose NAT form carries a single port (a +// firewalld forward-port, a pf rdr row) stores each spec as its own row, every +// row translating to the same target. +func expandNATPorts(r *NATRule) []*NATRule { + specs := r.PortSpecs() + if len(specs) <= 1 { + return []*NATRule{r} + } + out := make([]*NATRule, 0, len(specs)) + for _, pr := range specs { + c := *r + c.Port, c.Ports = 0, nil + natPortSpecsToRule(&c, []PortRange{pr}) + out = append(out, &c) + } + return out +} + +// cells returns the concrete rules r covers: the cross product of its merged +// axes, each expanded to the values it spans. A FamilyAny + TCPUDP + DirAny rule +// yields eight cells; a fully concrete rule yields itself. The direction expansion +// role-swaps the outbound half, so each cell is stated in its own natural frame — +// covers compares in the inbound frame, so that swap round-trips. On a backend with +// no output concept (outputSupported false) the direction axis does not distinguish +// two rules, so it is not expanded. The port axis expands a list to one cell per +// spec, keeping each range whole, so a stored set of single-port rows covers a +// list rule on a backend that fans lists out (firewalld, pf). +func (r *Rule) cells(outputSupported bool) []*Rule { + dirs := []*Rule{r} + if outputSupported { + dirs = expandDirections(r) + } + var out []*Rule + for _, d := range dirs { + for _, p := range expandProtocols(d) { + for _, fam := range expandFamilies(p) { + out = append(out, expandPorts(fam)...) + } + } + } + return out +} + +// coveredBy is the coverage relation behind CoveredBy, with the direction axis +// gated on whether the backend distinguishes output rules at all. +func (r *Rule) coveredBy(rules []*Rule, outputSupported bool) bool { + for _, cell := range r.cells(outputSupported) { + covered := false + for _, have := range rules { + if have.covers(cell, outputSupported) { + covered = true + break + } + } + if !covered { + return false + } + } + return true +} + +// CoveredBy reports whether every concrete rule the receiver spans is covered by at +// least one rule in rules. It is the set form of Covers, and its inverse: where +// a.Covers(b) asks whether one rule contains another, b.CoveredBy([]*Rule{a}) asks +// whether a set contains one. +// +// A rule that spans several axes is rarely stored as one object: GetRules reports the +// firewall's actual rows, so a rule the caller authored as FamilyAny may read back as +// an IPv4 row and an IPv6 row on a backend that cannot store one family-agnostic row. +// Such a rule is fully present in the set even though no single member Covers it, so +// coverage is decided cell by cell rather than rule by rule. A caller uses it to +// decide whether a rule is already installed before adding it. +// +// It expands the receiver across family, transport, direction and ports and +// requires every resulting cell to be covered, so a rule spanning both transports +// is not reported present when only its TCP half is, and a port-list rule is not +// reported present when only some of its ports are. The receiver is not modified. +func (r *Rule) CoveredBy(rules []*Rule) bool { + return r.coveredBy(rules, true) +} + +// MatchesAny reports whether the receiver is the same underlying rule as any of +// targets, honoring direction but ignoring the comment, which is not part of rule +// identity. It is the set form of Equal, and unlike CoveredBy it demands identity +// rather than coverage: a TCPUDP target does not match its TCP half. It backs +// comment-agnostic removal, where a stored row is acted on when it means the same +// rule as one the caller named, however that row was spelled or commented. +func (r *Rule) MatchesAny(targets []*Rule) bool { + for _, t := range targets { + if r.Equal(t, true) { + return true + } + } + return false +} + +// OrphanLogMatchesAny reports whether the receiver is a log-only row — a LOG rule +// whose action partner is gone, carried as Log with no action — belonging to a +// logged rule named by one of targets. A logged rule is stored as a LOG row plus +// the action row under it; when the action row is edited away by hand the stray +// LOG row no longer reads back as a rule, and this is how a removal still claims +// it. It reports false for any receiver that carries an action, so a complete +// rule is matched by MatchesAny alone. +func (r *Rule) OrphanLogMatchesAny(targets []*Rule) bool { + if r.Action != ActionInvalid || !r.Log { + return false + } + for _, t := range targets { + if !t.Log { + continue + } + // Compare against the target stripped of its action, which is the shape the + // surviving LOG row encodes. + tl := *t + tl.Action = ActionInvalid + if r.Equal(&tl, true) { + return true + } + } + return false +} + +// splitDualRowProtocol returns the rule a backend must re-add after deleting one +// transport of a genuine TCPUDP row — a single stored rule covering both TCP and +// UDP — to satisfy a concrete-protocol removal: the surviving opposite transport. +// It mirrors splitDualRow for the protocol axis. It returns nil when no split +// applies: the matched row is not a merged TCPUDP row, or the target names no +// single transport (so the whole row goes). +func splitDualRowProtocol(matched, target *Rule) *Rule { + if matched.Proto != TCPUDP { + return nil + } + opp := oppositeProtocol(target.Proto) + if opp == ProtocolAny { + return nil + } + s := *matched + s.Proto = opp + return &s +} + +// splitMergedRow returns the rows a backend must re-add after deleting a single +// stored row that covered more than the caller targeted. A row may be merged on two +// axes at once — nftables' inet table holds a FamilyAny rule as one unpinned row, +// and a TCPUDP rule as one `meta l4proto { tcp, udp }` row — so removing one cell of +// that family×transport grid can leave a remainder that needs two rows to express. +// It composes the per-axis splits: the untargeted family keeps the row's full +// transport coverage, and the untargeted transport is then scoped to the family the +// target named, so the two rows never overlap. It returns nil when the target covers +// the whole row. +func splitMergedRow(matched, target *Rule) []*Rule { + var out []*Rule + if s := splitDualRow(matched, target); s != nil { + out = append(out, s) + } + if s := splitDualRowProtocol(matched, target); s != nil { + // A family split above already re-added the untargeted family across both + // transports, so this row must not repeat it: pin it to the targeted family. + if len(out) > 0 { + s.Family = target.impliedFamily() + } + out = append(out, s) + } + return out +} + +// coversProtocol reports whether an existing rule's protocol (have) already covers a +// caller rule's protocol want (the asymmetric add/dedup form). TCPUDP spans TCP and +// UDP, so it covers either; every other protocol covers only itself. ProtocolAny is +// not a merged value — it matches every IP protocol — so it covers only ProtocolAny. +func coversProtocol(have, want Protocol) bool { + if have == want { + return true + } + return have == TCPUDP && (want == TCP || want == UDP) +} + +// coversPorts reports whether an existing rule's port specs (have) cover a caller +// rule's (want) — the port-axis analog of coversProtocol: every port want spans +// lies inside a range have carries, so a list covers its elements and a range its +// interior, never the reverse. Both sides are compared in coalesced canonical +// form, so contiguous have ranges merge over a want range while a discrete have +// set covers only the ports it names, never the gaps between them. An empty want +// is always covered; an empty have covers only an empty want. +func coversPorts(have, want []PortRange) bool { + hc := coalescePortRanges(have) + for _, w := range coalescePortRanges(want) { + covered := false + for _, h := range hc { + if h.Start <= w.Start && w.End <= h.End { + covered = true + break + } + } + if !covered { + return false + } + } + return true +} + +// coversProtocolRemoval reports whether two rules touch a common transport (the +// symmetric remove/move form): a TCPUDP on either side spans TCP and UDP, so it +// touches either concrete transport and vice versa. +func coversProtocolRemoval(a, b Protocol) bool { + if a == b { + return true + } + if a == TCPUDP && (b == TCP || b == UDP) { + return true + } + if b == TCPUDP && (a == TCP || a == UDP) { + return true + } + return false +} + +// protoNeutralized returns a copy of r with its protocol cleared to TCPUDP when it +// sits on the merged tcp/udp axis, so the field compare in EqualForDedup and +// EqualForRemoval does not re-test a protocol coversProtocol has already gated. +// Every other protocol is returned unchanged, keeping matchFields' exact protocol +// equality for rules that never merge. +func (r *Rule) protoNeutralized() *Rule { + if !onProtocolAxis(r.Proto) { + return r + } + c := *r + c.Proto = TCPUDP + return &c +} + +// portsNeutralized returns a copy of r with both port axes cleared, so the field +// compare in covers does not re-test an axis coversPorts has already gated — a +// port-list row must absorb its single-port cells. It mirrors protoNeutralized +// for the port axes. +func (r *Rule) portsNeutralized() *Rule { + c := *r + c.Port, c.Ports, c.SourcePort, c.SourcePorts = 0, nil, 0, nil + return &c +} + +// onProtocolAxis reports whether a protocol participates in the tcp/udp merge: +// the two concrete transports and their merged TCPUDP form. +func onProtocolAxis(p Protocol) bool { + return p == TCP || p == UDP || p == TCPUDP +} + +// splitDualRowDirection returns the rule a backend must re-add after deleting one +// direction of a genuine DirAny row — a single stored object covering both the +// input and output directions — to satisfy a concrete-direction removal: the +// surviving opposite-direction rule, materialized in its natural frame. It mirrors +// splitDualRow for the direction axis. It returns nil when no split applies: the +// matched row is itself a concrete direction (it removes only itself, its twin +// living in a separate physical row), or the target is direction-agnostic +// (DirAny/DirForward, so the whole rule is meant to go). Backends whose model +// cannot express the surviving single-direction rule reject the removal with +// ErrUnsupported instead of calling this. +func splitDualRowDirection(matched, target *Rule) *Rule { + if matched.Direction != DirAny { + return nil + } + switch target.Direction { + case DirInput: + // The input cell is removed; the output cell survives, in its natural + // outbound frame (the stored DirAny row is inbound-framed). + s := matched.directionSwapped() + s.Direction = DirOutput + return s + case DirOutput: + // The output cell is removed; the input cell survives unchanged in frame. + s := *matched + s.Direction = DirInput + return &s + default: + return nil + } +} + +// dirAnyInputFallback maps a DirAny rule to its input half on a backend that has no +// output concept (Capabilities().Output is false), where the two directions cannot +// be distinguished: a both-directions rule degrades to an input rule rather than +// being rejected. Such a backend applies it at the top of AddRule/RemoveRule. The +// input half keeps every field (DirAny is authored in the inbound frame), only the +// direction changes. On an output-capable backend DirAny is fanned out via +// expandDirections instead, so a non-DirAny rule — or a rule on a backend that does +// distinguish output — is returned unchanged. +func dirAnyInputFallback(r *Rule, outputSupported bool) *Rule { + if r.Direction == DirAny && !outputSupported { + in := *r + in.Direction = DirInput + return &in + } + return r +} + +// checkICMPType reports an ICMP type set on a non-ICMP rule, which is +// meaningless. Backends that honor ICMPType call it to reject such a rule. +func (r *Rule) checkICMPType() error { + if r.ICMPType != nil && !r.Proto.IsICMP() { + return fmt.Errorf("an icmp type requires the icmp or icmpv6 protocol") + } + return nil +} + +// CheckExpandedProtocol reports a TCPUDP rule reaching a row-level marshaller. +// TCPUDP is a merged, logical protocol: a backend with no both-transports form fans +// it into a tcp row and a udp row with expandProtocols before marshalling, so a +// TCPUDP rule arriving here means that fan-out was skipped. Backends whose native +// syntax does carry both transports in one row (nftables' `meta l4proto { tcp, udp }`) +// do not call it. +func (r *Rule) CheckExpandedProtocol() error { + if r.Proto == TCPUDP { + return fmt.Errorf("the tcpudp protocol matches two transports and must be expanded to a tcp and a udp rule") + } + return nil +} + +// validate reports whether the filter rule is well formed independent of any +// backend, mirroring NATRule.validate. Every backend's entry points call it first +// so a fundamentally malformed rule fails uniformly: an ICMP type set on a +// non-ICMP protocol, a port match with no port-carrying transport, or an +// interface bound to the side its direction cannot see. It holds only checks +// every backend shares; a backend-specific limit lives in that backend's +// validateRule. +func (r *Rule) validate() error { + if err := r.checkICMPType(); err != nil { + return err + } + // A port match requires a concrete port-carrying transport (tcp/udp/sctp); the + // allowed set narrows per backend, but no backend can match a port with none. + if r.PortNeedsConcreteProtocol() { + return fmt.Errorf("a port requires a concrete transport protocol") + } + // An interface match must sit on the side the rule's direction can observe: a + // packet an input rule matches has not yet been routed to an outgoing + // interface, and one an output rule matches never arrived on an incoming one. + // The forward direction sees both, so it accepts either, and the check is + // strict on IsInput/IsOutput so a DirAny rule — authored in the inbound frame + // and swapped per direction by expandDirections — is judged on its concrete + // halves. Backends that cannot express an interface at all reject any + // interface match separately. + if r.IsOutput() && r.InInterface != "" { + return fmt.Errorf("an input interface cannot be matched on an output rule") + } + if r.IsInput() && r.OutInterface != "" { + return fmt.Errorf("an output interface cannot be matched on an input rule") + } + return nil +} + +// numberByDirection assigns each rule a 1-based Number within its direction +// (input, output or forward), in slice order. Backends whose input, output and +// forward chains are ordered independently (iptables, nftables) number rules +// this way so a rule's Number matches the InsertRule/MoveRule position for its +// chain. A DirAny rule counts in the input bucket — its Number reflects the input +// chain, as a FamilyAny rule's Number reflects the IPv4 chain. It is derived on +// read and, like HasPrefix, ignored on add and not part of rule identity. +func numberByDirection(rules []*Rule) { + var in, out, fwd int + for _, r := range rules { + switch r.Direction { + case DirOutput: + out++ + r.Number = out + case DirForward: + fwd++ + r.Number = fwd + default: + // DirInput and DirAny both number in the input chain. + in++ + r.Number = in + } + } +} + +// numberSequential assigns each rule a 1-based Number in slice order, for a backend +// that evaluates all its filter rules as one ordered list whose position spans +// directions (pf's anchor, ufw's numbered list). +func numberSequential(rules []*Rule) { + for i, r := range rules { + r.Number = i + 1 + } +} + +// NATKind is the kind of network address translation a NATRule performs. +type NATKind uint8 + +const ( + // NATInvalid is the zero value of NATKind, meaning no translation; it is + // rejected when authoring a NAT rule. + NATInvalid NATKind = iota + // DNAT rewrites the destination of matching inbound packets to ToAddress + // (and ToPort when set) — a classic port-forward to another host. + DNAT + // Redirect sends matching inbound packets to a port on the local host + // (ToPort). It is destination NAT to this machine and takes no ToAddress. + Redirect + // SNAT rewrites the source of matching outbound packets to the fixed + // ToAddress. + SNAT + // Masquerade rewrites the source of matching outbound packets to the + // address of the outgoing interface, chosen dynamically at send time. + Masquerade +) + +// String returns the canonical lower-case name of the NAT kind. +func (k NATKind) String() string { + switch k { + case DNAT: + return "dnat" + case Redirect: + return "redirect" + case SNAT: + return "snat" + case Masquerade: + return "masquerade" + } + return "invalid" +} + +// ParseNATKind parses a NAT-kind token (case-insensitive), accepting only the +// concrete kinds NATKind.String emits. The sentinel "invalid" (NATInvalid) is +// rejected, mirroring ParseAction, so callers cannot author a NAT rule with no +// real kind; backup decoding round-trips it separately in NATKind.UnmarshalJSON. +func ParseNATKind(s string) (NATKind, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "dnat": + return DNAT, nil + case "redirect": + return Redirect, nil + case "snat": + return SNAT, nil + case "masquerade": + return Masquerade, nil + } + return 0, fmt.Errorf("unknown nat kind %q", s) +} + +// isSource reports whether the kind performs source NAT (applied to outbound +// traffic in the postrouting stage) as opposed to destination NAT (inbound, +// prerouting). +func (k NATKind) isSource() bool { + return k == SNAT || k == Masquerade +} + +// A NATRule describes a network-address-translation rule: a port-forward +// (DNAT/Redirect) applied to inbound traffic, or source NAT (SNAT/Masquerade) +// applied to outbound traffic. NAT rules are managed separately from filter +// Rules through the Manager's NAT methods. +type NATRule struct { + // Kind is the translation to perform. + Kind NATKind + // Family is the IP family the rule targets. FamilyAny is resolved from the + // translation target or a matched address when left unset. + Family Family + // Proto is the network protocol the rule matches. + Proto Protocol + // Interface matches the inbound interface for DNAT/Redirect and the + // outbound interface for SNAT/Masquerade. Empty means any interface. + Interface string + // Source matches the packet's source address or CIDR, with the same + // semantics as Rule.Source. Empty matches any source. + Source string + // Destination matches the packet's destination address or CIDR, with the + // same semantics as Rule.Destination. Empty matches any destination. + Destination string + // Port is the single destination port the rule matches; Ports takes + // precedence when non-empty. + Port uint16 + // Ports is a list of destination port ranges the rule matches. + Ports []PortRange + // ToAddress is the translation target: the new destination for DNAT, the + // new source for SNAT. It is empty for Redirect and Masquerade. + ToAddress string + // ToPort is the translation target port for DNAT/Redirect (0 leaves the + // port unchanged). It is unused for SNAT/Masquerade. + ToPort uint16 + // HasPrefix reports whether the rule carries the library's configured + // prefix, mirroring Rule.HasPrefix. It is derived on read and purely + // informational. + HasPrefix bool + // Number is the rule's 1-based position within its nat chain, populated by + // GetNATRules on backends that advertise Capabilities().RuleOrdering. It + // mirrors the position argument of InsertNATRule and MoveNATRule. + Number int + // table records the container a container backend read this NAT rule from; + // it backs HasPrefix. + table string +} + +// PortSpecs returns the rule's matched destination ports as a normalized list. +func (r *NATRule) PortSpecs() []PortRange { + return portSpecsFor(r.Port, r.Ports) +} + +// HasPorts reports whether the rule matches on any destination port. +func (r *NATRule) HasPorts() bool { + return r.Port != 0 || len(r.Ports) > 0 +} + +// HasPortSet reports whether the rule matches more than a single discrete port. +func (r *NATRule) HasPortSet() bool { + specs := r.PortSpecs() + if len(specs) > 1 { + return true + } + return len(specs) == 1 && specs[0].Start != specs[0].End +} + +// impliedFamily returns the family the rule effectively targets, inferring it +// from the translation, destination or source address when unspecified. +func (r *NATRule) impliedFamily() Family { + if r.Family != FamilyAny { + return r.Family + } + for _, a := range []string{r.ToAddress, r.Destination, r.Source} { + if f := familyOfAddr(a); f != FamilyAny { + return f + } + } + return FamilyAny +} + +// expandNATFamilies returns the concrete-family rows a NAT rule materializes +// into: itself when it already targets one family, or an IPv4 row plus an IPv6 +// row when it targets both. It is the NAT analog of expandFamilies. +func expandNATFamilies(r *NATRule) []*NATRule { + if r.impliedFamily() != FamilyAny { + return []*NATRule{r} + } + v4, v6 := *r, *r + v4.Family, v6.Family = IPv4, IPv6 + return []*NATRule{&v4, &v6} +} + +// filterNATFamiliesIPv6 returns the expandNATFamilies rows narrowed to the +// families the backend enforces, the NAT analog of filterFamiliesIPv6: with +// ipv6Enabled false, the IPv6 row of a family-agnostic rule is dropped rather +// than written as a line the backend would never enforce. A row already pinned +// to a concrete family keeps it, IPv6 included, so Restore can reproduce a +// snapshot verbatim. +func filterNATFamiliesIPv6(ipv6Enabled bool, r *NATRule) []*NATRule { + rows := expandNATFamilies(r) + if ipv6Enabled || len(rows) == 1 { + return rows + } + kept := make([]*NATRule, 0, 1) + for _, row := range rows { + if row.Family != IPv6 { + kept = append(kept, row) + } + } + return kept +} + +// validate reports whether the rule is well formed for its kind. It is called +// by every backend before marshaling so an ill-formed rule fails uniformly. +func (r *NATRule) validate() error { + switch r.Kind { + case DNAT: + if r.ToAddress == "" { + return fmt.Errorf("dnat requires a translation address") + } + case Redirect: + if r.ToAddress != "" { + return fmt.Errorf("redirect translates to a local port, not an address") + } + if r.ToPort == 0 { + return fmt.Errorf("redirect requires a translation port") + } + case SNAT: + if r.ToAddress == "" { + return fmt.Errorf("snat requires a translation address") + } + case Masquerade: + if r.ToAddress != "" || r.ToPort != 0 { + return fmt.Errorf("masquerade takes no translation target") + } + default: + return fmt.Errorf("invalid nat kind") + } + // TCPUDP is a multi-state, logical protocol with no NAT form: a translation is applied + // per transport, and no backend's NAT syntax carries both in one rule. The filter + // path fans TCPUDP out with expandProtocols; NAT has no such fan-out, so reject it + // here rather than let a backend emit a `tcpudp` protocol token. A caller wanting + // both transports translated adds a tcp rule and a udp rule. + if r.Proto == TCPUDP { + return fmt.Errorf("nat rules take a single transport; add a tcp rule and a udp rule: %w", ErrUnsupportedNAT) + } + if portNeedsConcreteProtocol(r.Port, r.Ports, r.Proto) { + return fmt.Errorf("a port requires a tcp or udp protocol") + } + // A translation port (DNAT/Redirect ToPort) is only valid when the rule + // carries a port-bearing protocol: iptables' DNAT/REDIRECT/SNAT targets and + // nft's dnat/redirect reject a target port without tcp/udp/sctp. Redirect + // always sets ToPort, so this also guards a bare Redirect left ProtocolAny. + if r.ToPort != 0 && !r.Proto.HasPorts() { + return fmt.Errorf("a translation port requires a tcp or udp protocol") + } + return nil +} + +// EqualBase reports whether two NAT rules describe the same translation and +// match, ignoring the IP family (mirroring Rule.EqualBase). Backends use it to +// deduplicate and remove rules regardless of a FamilyAny/concrete distinction. +func (r *NATRule) EqualBase(o *NATRule) bool { + if r.Kind != o.Kind || r.Proto != o.Proto { + return false + } + if r.Interface != o.Interface || !addrEqual(r.Source, o.Source) || !addrEqual(r.Destination, o.Destination) { + return false + } + if !portRangesEqual(r.PortSpecs(), o.PortSpecs()) { + return false + } + // ToAddress is compared through addrEqual, like Source/Destination: a backend + // re-spells the translation target on read (IPv6 zero-compression/case, a /32 + // host prefix), so a byte-for-byte compare would fail to dedup an existing NAT + // rule and RemoveNATRule would fail to find it. + return addrEqual(r.ToAddress, o.ToAddress) && r.ToPort == o.ToPort +} + +// Equal reports whether two NAT rules are the same, including the IP family. +// Family is compared through impliedFamily so a FamilyAny rule matches the +// concrete family a backend stores it under (mirroring Rule.Equal). +func (r *NATRule) Equal(o *NATRule) bool { + return r.impliedFamily() == o.impliedFamily() && r.EqualBase(o) +} + +// EqualForDedup is the NAT-rule add guard mirroring Rule.EqualForDedup: the same +// base translation, and the receiver's family covers o's. +func (r *NATRule) EqualForDedup(o *NATRule) bool { + return r.Covers(o) +} + +// Covers reports whether the receiver's coverage contains o's, mirroring Rule.Covers +// for NAT rules. A FamilyAny rule covers either family and a concrete one covers +// only itself; a match-port list or range covers the ports it contains. NAT has no +// direction axis and a translation applies per transport, so family and ports are +// the only axes a NAT rule spans. +func (r *NATRule) Covers(o *NATRule) bool { + return coversFamily(r.impliedFamily(), o.impliedFamily()) && + coversPorts(r.PortSpecs(), o.PortSpecs()) && + r.portsNeutralized().EqualBase(o.portsNeutralized()) +} + +// portsNeutralized returns a copy of r with its match ports cleared, so the field +// compare in Covers does not re-test an axis coversPorts has already gated. It +// mirrors Rule.portsNeutralized. +func (r *NATRule) portsNeutralized() *NATRule { + c := *r + c.Port, c.Ports = 0, nil + return &c +} + +// CoveredBy reports whether every concrete NAT rule the receiver spans is covered by +// at least one rule in rules, mirroring Rule.CoveredBy. A FamilyAny receiver requires +// both families to be covered, whether by one FamilyAny rule or by an IPv4 rule and +// an IPv6 rule; a match-port list requires every port to be covered, whether by one +// list rule or by a row per port on a backend that fans lists out. +func (r *NATRule) CoveredBy(rules []*NATRule) bool { + for _, cell := range r.cells() { + covered := false + for _, have := range rules { + if have.Covers(cell) { + covered = true + break + } + } + if !covered { + return false + } + } + return true +} + +// cells returns the concrete NAT rules the receiver spans: the cross product of +// its family and match-port expansions, the NAT analog of Rule.cells. +func (r *NATRule) cells() []*NATRule { + fams := []*NATRule{r} + if r.impliedFamily() == FamilyAny { + v4, v6 := *r, *r + v4.Family, v6.Family = IPv4, IPv6 + fams = []*NATRule{&v4, &v6} + } + var out []*NATRule + for _, fam := range fams { + out = append(out, expandNATPorts(fam)...) + } + return out +} + +// EqualForRemoval is the NAT-rule remove guard mirroring Rule.EqualForRemoval: +// the same base translation, and o's family may touch the receiver row's. +func (r *NATRule) EqualForRemoval(o *NATRule) bool { + ft, fr := o.impliedFamily(), r.impliedFamily() + return (ft == FamilyAny || fr == FamilyAny || ft == fr) && r.EqualBase(o) +} + +// numberNATByChain assigns each NAT rule a 1-based Number within its nat chain — +// prerouting for destination NAT, postrouting for source NAT — matching the +// InsertNATRule/MoveNATRule position on chain-ordered backends (iptables, nftables). +func numberNATByChain(rules []*NATRule) { + var pre, post int + for _, r := range rules { + if r.Kind.isSource() { + post++ + r.Number = post + } else { + pre++ + r.Number = pre + } + } +} + +// numberNATSequential assigns each NAT rule a 1-based Number in slice order, for a +// backend that evaluates its translation rules as one ordered list (pf). +// +//nolint:unused // only the pf backend needs it, and the authoritative `unused` run is GOOS=linux. +func numberNATSequential(rules []*NATRule) { + for i, r := range rules { + r.Number = i + 1 + } +} + +// splitNATDualRow is splitDualRow's NAT analog: the copy of a genuine +// dual-family NAT row (a single stored translation with no family pin) that a +// backend re-adds after deleting it for a concrete-family removal, pinned to +// the family the caller did NOT target. It returns nil when no split applies. +// Family is the only axis a NAT rule spans. +func splitNATDualRow(matched, target *NATRule) *NATRule { + tf := target.impliedFamily() + if tf == FamilyAny || matched.impliedFamily() != FamilyAny { + return nil + } + opp := *matched + opp.Family = oppositeFamily(tf) + return &opp +} + +// Backup is a portable snapshot of the state a backend manages. It can be used +// to restore that state later via Manager.Restore. +type Backup struct { + // Filter rules saved in the order they were returned by GetRules. + Rules []*Rule + // NAT rules saved in the order they were returned by GetNATRules. + NATRules []*NATRule + // DefaultPolicy is the per-direction default action captured at backup time, + // on backends that advertise Capabilities().DefaultPolicy; nil otherwise. A + // direction the backend does not expose is ActionInvalid and is left unchanged + // on Restore. Capturing it lets Restore re-assert a restrictive policy (e.g. a + // default DROP) rather than silently inherit the restore host's current one. + DefaultPolicy *DefaultPolicy + // AddressSets are the named address sets (ipsets, nftables sets, pf tables) the + // backend manages, captured on backends that advertise + // Capabilities().AddressSets; nil otherwise. Restore recreates them before the + // filter rules so a set-referencing rule (@set) resolves on a host that does + // not yet have the set. + AddressSets []*AddressSet +} + +// Capabilities advertises which features a backend can express. It lets a +// caller detect support before trial-and-error: a false field means the +// corresponding operation returns an unsupported error (or, for RuleCounters, +// simply reports zero). +type Capabilities struct { + // Output is true when the backend distinguishes input from output rules. + Output bool + // Forward is true when the backend can express a rule in the forward (routing) + // chain. A false Forward means a rule with Direction DirForward is rejected + // with ErrUnsupportedForward. + Forward bool + // Zones is true when the backend maps interfaces to zones. + Zones bool + // Priority is true when per-rule priority is honored. + Priority bool + // IPv6 is true when the backend manages IPv6 rules at all. It is host-resolved + // on the backends whose own configuration can disable IPv6 (csf, apf) or whose + // packaging can omit it (iptables); a false IPv6 means every IPv6 rule shape, + // ICMPv6 included, is unmanageable, not merely rejected on one axis. (IPv4 is + // managed by every backend, so it is not advertised as a capability.) + IPv6 bool + // PortPair is true when a source-port match can be combined with a + // destination-port match in one rule. A false PortPair means such a rule is + // rejected with ErrUnsupportedSourcePort (a firewalld rich rule carries a + // single port element). + PortPair bool + // ConnState is true when connection-tracking state can be matched. + ConnState bool + // InterfaceMatch is true when a rule can bind to a per-rule interface (as + // opposed to a zone). + InterfaceMatch bool + // Logging is true when per-rule packet logging is honored. + Logging bool + // RateLimit is true when per-rule rate limiting is honored. + RateLimit bool + // ConnLimit is true when per-rule connection limiting is honored. + ConnLimit bool + // NAT is true when AddNATRule/RemoveNATRule/GetNATRules are supported. + NAT bool + // RuleOrdering is true when InsertRule/MoveRule are supported, and when NAT is + // also true, InsertNATRule/MoveNATRule. + RuleOrdering bool + // DefaultPolicy is true when GetDefaultPolicy/SetDefaultPolicy are supported. + DefaultPolicy bool + // RuleCounters is true when GetRules populates the Packets/Bytes fields. + RuleCounters bool + // AddressSets is true when the address-set methods are supported. + AddressSets bool + // Comments is true when a rule's Comment field round-trips: it is stored on + // AddRule and populated by GetRules. A false Comments means the backend + // silently ignores the Comment field. + Comments bool + // Negation is true when a "!"-negated Source/Destination match is honored. + // A false Negation means such a rule is rejected with ErrUnsupported (WFP + // has no negated address condition). + Negation bool + // RejectAction is true when the Reject action — refuse with an error + // response, as opposed to a silent Drop — is expressible. A false + // RejectAction means a Reject rule is rejected with ErrUnsupported (WFP + // only permits or blocks). + RejectAction bool + // FamilyWithoutAddress is true when a rule with a concrete Family but no + // Source/Destination is expressible. A false value means family scoping + // rides on addresses alone and such a rule is rejected with ErrUnsupported + // (a WFP filter scopes family through its address conditions). + FamilyWithoutAddress bool + // DenyActionFromConfig is true when the backend's native deny store carries + // no per-entry action — the firewall tool applies the deny action its own + // config names (csf.conf DROP, conf.apf ALL_STOP). A deny added with the + // config's action is stored natively and reads back with it; one with a + // differing action is expressed elsewhere (the raw-iptables hook). Because + // the native entry encodes no action, RemoveRule clears it whatever action + // the removal target names. + DenyActionFromConfig bool +} + +// The backend type strings Manager.Type reports, one per backend. They are declared +// here beside the interface rather than beside each implementation because every +// backend lives behind a build tag for its own platform: a caller — or a test — that +// branches on mgr.Type() must be able to name any backend on any platform, not only +// the ones that compile for the host. +const ( + IPTablesType = "iptables" + NFTType = "nftables" + UFWType = "ufw" + FirewallDType = "firewalld" + CSFType = "csf" + APFType = "apf" + PFType = "pf" + WFType = "windows-firewall" +) + +// Manager is the standard firewall manager interface. +// +// Every method that performs I/O (shelling out, D-Bus, or the Windows API) +// takes a context.Context as its first argument so callers can apply timeouts +// and cancellation. Type and Capabilities are pure and take none. +type Manager interface { + // Type returns the manager type. + Type() string + + // Capabilities returns the set of features this backend can express. + Capabilities() Capabilities + + // GetZone returns the zone for the specified interface. + GetZone(ctx context.Context, iface string) (string, error) + + // GetRules returns the existing filter rules from the zone. + GetRules(ctx context.Context, zoneName string) ([]*Rule, error) + + // AddRule adds a rule to the zone. + AddRule(ctx context.Context, zoneName string, rule *Rule) error + + // InsertRule adds rule at the given position. position uses 1-based indexing + // (1 = first rule); a non-positive position is treated as 1, and a position + // larger than the current rule count appends the rule. Backends that do not + // support ordered rules return an error. + InsertRule(ctx context.Context, zoneName string, position int, rule *Rule) error + + // MoveRule moves an existing rule to the given position. position uses 1-based + // indexing; a non-positive position is treated as 1, and a position larger + // than the current rule count moves the rule to the end. Backends that do not + // support ordered rules return an error. + MoveRule(ctx context.Context, zoneName string, rule *Rule, position int) error + + // RemoveRule removes a rule from the zone. + RemoveRule(ctx context.Context, zoneName string, rule *Rule) error + + // GetNATRules returns the existing NAT rules from the zone. Backends without + // NAT support return an unsupported error. + GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) + + // AddNATRule adds a NAT rule to the zone. + AddNATRule(ctx context.Context, zoneName string, rule *NATRule) error + + // InsertNATRule adds a NAT rule at the given position within its nat chain. + // position uses 1-based indexing (1 = first rule in that chain); a non-positive + // position is treated as 1, and a position larger than the chain's current rule + // count appends the rule. Backends that do not support ordered rules return an + // error; backends without NAT support return the NAT sentinel. + InsertNATRule(ctx context.Context, zoneName string, position int, rule *NATRule) error + + // MoveNATRule moves an existing NAT rule to the given position within its nat + // chain. position uses 1-based indexing; a non-positive position is treated as + // 1, and a position larger than the chain's current rule count moves the rule + // to the end. Backends that do not support ordered rules return an error; + // backends without NAT support return the NAT sentinel. + MoveNATRule(ctx context.Context, zoneName string, rule *NATRule, position int) error + + // RemoveNATRule removes a NAT rule from the zone. + RemoveNATRule(ctx context.Context, zoneName string, rule *NATRule) error + + // GetDefaultPolicy returns the default action applied to packets that match + // no rule. A direction the backend cannot express is returned as + // ActionInvalid. Backends that cannot manage a default policy at all return + // an unsupported error. + GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) + + // SetDefaultPolicy sets the default action for the directions named in + // policy. A direction left as ActionInvalid is left unchanged. Backends that + // cannot manage a default policy return an unsupported error. + SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error + + // GetAddressSets returns the address sets managed by this backend. Backends + // that cannot manage address sets return an unsupported error. + GetAddressSets(ctx context.Context) ([]*AddressSet, error) + + // GetAddressSet returns a single address set by name, or an error if it does + // not exist. Backends that cannot manage address sets return an unsupported + // error. + GetAddressSet(ctx context.Context, name string) (*AddressSet, error) + + // AddAddressSet creates an address set. Adding a set that already exists (by + // name) is a no-op. Backends that cannot manage address sets return an + // unsupported error. + AddAddressSet(ctx context.Context, set *AddressSet) error + + // RemoveAddressSet removes an address set by name. Backends that cannot + // manage address sets return an unsupported error. + RemoveAddressSet(ctx context.Context, name string) error + + // AddAddressSetEntry adds an entry to the named set. Backends that cannot + // manage address sets return an unsupported error. + AddAddressSetEntry(ctx context.Context, name, entry string) error + + // RemoveAddressSetEntry removes an entry from the named set. Backends that + // cannot manage address sets return an unsupported error. + RemoveAddressSetEntry(ctx context.Context, name, entry string) error + + // Backup captures the current filter and NAT rules the manager reports, plus — + // on backends that advertise them — the default policy and the managed address + // sets. On container backends (nftables table, pf anchor, firewalld zone) this + // is scoped to the library's container by construction; on tag/comment backends + // it is the whole chain, foreign rules included. It does not filter on the + // HasPrefix flag. + Backup(ctx context.Context, zoneName string) (*Backup, error) + + // Restore reconciles the firewall to the contents of a Backup. The captured + // address sets are recreated first (so a set-referencing rule resolves), then + // existing filter and NAT rules the backend acts on are removed and the backup + // rules added, and finally the captured default policy is re-asserted. Like Sync + // it reconciles the actual state and does not filter on HasPrefix. + Restore(ctx context.Context, zoneName string, backup *Backup) error + + // Reload reloads the manager to activate new rules. + Reload(ctx context.Context) error + + // Close closes the connection to the manager. + Close(ctx context.Context) error +} + +// Sync reconciles the zone's filter rules toward desired: it removes any rule +// the backend reports that desired does not cover and adds desired rules that are +// not yet present, leaving rules already in place untouched. It reconciles the +// actual firewall state and does not filter on HasPrefix — a rule without the +// configured prefix (HasPrefix=false) is reconciled like any other, so a foreign +// rule not in desired is removed. A rule unchanged between existing and desired is +// never removed and re-added, but removal still runs as its own pass before +// additions, so a desired set that shares nothing with the existing rules is not +// applied atomically. Sync reports how many rules were added and removed. +// +// The diff is a coverage relation, not rule-for-rule equality, because GetRules +// reports the firewall's actual rows and a backend stores a rule the way its model +// allows: iptables holds a FamilyAny rule as an IPv4 row plus an IPv6 row, pf holds +// a DirAny rule as an inbound row plus an outbound row, while nftables holds either +// as one row. An existing rule is kept when every concrete cell it spans is wanted +// (Rule.CoveredBy over desired), and a desired rule is added when some cell it spans +// is not yet present (its CoveredBy over existing). Comparing this way keeps Sync a +// no-op against its own output whichever representation the backend chose, where +// plain equality would remove-and-re-add every fanned-out rule on each run. A rule +// only partially covered by desired is removed whole and the wanted part re-added. +// The Comment, HasPrefix and Packets/Bytes fields never affect the diff. +func Sync(ctx context.Context, mgr Manager, zoneName string, desired []*Rule) (added, removed int, err error) { + existing, err := mgr.GetRules(ctx, zoneName) + if err != nil { + return 0, 0, err + } + outputSupported := mgr.Capabilities().Output + + // Remove any existing rule desired does not fully cover. Sync reconciles the + // actual firewall state toward desired, so any rule the backend reports and can + // act on is fair game; backends whose mutations are scoped to a private + // table/anchor simply no-op on rules outside it. A rule whose cells are spread + // across several desired rules is still fully wanted and is kept. + kept := make([]*Rule, 0, len(existing)) + for _, e := range existing { + if e.coveredBy(desired, outputSupported) { + kept = append(kept, e) + continue + } + if err := mgr.RemoveRule(ctx, zoneName, e); err != nil { + return added, removed, err + } + removed++ + } + + // Add any wanted rule that is not already present. A rule already in the + // firewall — whoever created it — counts as present, so Sync does not add a + // duplicate of a rule the surviving rows already cover. Queued additions count + // as present too: adding a covered duplicate would over-count added. + var toAdd []*Rule + for _, d := range desired { + if d.coveredBy(kept, outputSupported) || d.coveredBy(toAdd, outputSupported) { + continue + } + toAdd = append(toAdd, d) + } + // Count additions as they land so the reported added stays accurate when a + // later add errors after several have already been applied. + for _, r := range toAdd { + if err := mgr.AddRule(ctx, zoneName, r); err != nil { + return added, removed, err + } + added++ + } + return added, removed, nil +} diff --git a/firewall_test.go b/firewall_test.go new file mode 100644 index 0000000..d9fdf59 --- /dev/null +++ b/firewall_test.go @@ -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"`) +} diff --git a/firewalld_linux.go b/firewalld_linux.go new file mode 100644 index 0000000..0203719 --- /dev/null +++ b/firewalld_linux.go @@ -0,0 +1,1788 @@ +package firewall + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + + firewalld "github.com/grmrgecko/go-firewalld" +) + +// FirewallD manages a firewalld instance over D-Bus, mapping the Manager +// interface onto firewalld's zones, rich rules, and ipsets. +type FirewallD struct { + Conn *firewalld.Conn + // rulePrefix is accepted for a consistent constructor signature across + // backends. firewalld organizes rules into zones rather than a private + // namespace, so the prefix is not applied to individual rules. + rulePrefix string +} + +// fwICMPv4Types maps firewalld's IPv4 icmp-type names to their numeric ICMP type, +// covering the stock firewalld icmptype set. A rich rule's icmp-type element +// carries the name; the model carries the number, so these tables translate +// between the two. Only types firewalld names as a plain type match are listed — +// a number absent here cannot be expressed and marshalling reports it. +var fwICMPv4Types = map[string]uint8{ + "echo-reply": 0, + "destination-unreachable": 3, + "source-quench": 4, + "redirect": 5, + "echo-request": 8, + "router-advertisement": 9, + "router-solicitation": 10, + "time-exceeded": 11, + "parameter-problem": 12, + "timestamp-request": 13, + "timestamp-reply": 14, +} + +// fwICMPv6Types maps firewalld's ICMPv6 icmp-type names to their numeric type. +// The same name (e.g. echo-request) resolves to a different number than IPv4, so +// the family selects the table. +var fwICMPv6Types = map[string]uint8{ + "destination-unreachable": 1, + "packet-too-big": 2, + "time-exceeded": 3, + "parameter-problem": 4, + "echo-request": 128, + "echo-reply": 129, + "router-solicitation": 133, + "router-advertisement": 134, + "neighbour-solicitation": 135, + "neighbour-advertisement": 136, + "redirect": 137, +} + +// NewFirewallD connects to firewalld and returns a manager, or an error when +// firewalld cannot be reached. +func NewFirewallD(ctx context.Context, rulePrefix string) (*FirewallD, error) { + // Attempt to connect, and failure means no firewalld. + conn, err := firewalld.Connect(ctx) + if err != nil { + return nil, err + } + _, err = conn.DefaultZone(ctx) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("firewalld cannot be reached: %s", err) + } + return &FirewallD{Conn: conn, rulePrefix: rulePrefix}, nil +} + +// Type returns the backend identifier for firewalld. +func (f *FirewallD) Type() string { + return FirewallDType +} + +// Capabilities reports which optional features this backend supports. +func (f *FirewallD) Capabilities() Capabilities { + return Capabilities{ + Output: false, + Zones: true, + Priority: true, + IPv6: true, + // A rich rule carries a single port element, so a source-port match + // cannot be combined with a destination-port match in one rule. + PortPair: false, + ConnState: false, + InterfaceMatch: false, + Logging: true, + RateLimit: true, + ConnLimit: false, + NAT: true, + RuleOrdering: false, + DefaultPolicy: true, + RuleCounters: false, + AddressSets: true, + Negation: true, + RejectAction: true, + FamilyWithoutAddress: true, + } +} + +// GetZone returns the firewalld zone bound to the interface, falling back to the +// default zone when the interface is unbound. +func (f *FirewallD) GetZone(ctx context.Context, iface string) (zoneName string, err error) { + // Ask firewalld directly which permanent zone the interface is bound to. + // This returns the zone id (e.g. "public"), which is what the other backend + // methods expect to pass back into Permanent().Zone. An empty result or an + // error means the interface is not bound to a zone, leaving the default + // zone below. + zoneName, err = f.Conn.Permanent().ZoneOfInterface(ctx, iface) + if err == nil && zoneName != "" { + return zoneName, nil + } + + // An unbound interface belongs to the default zone. + defaultZone, derr := f.Conn.DefaultZone(ctx) + if derr == nil && defaultZone != "" { + return defaultZone, nil + } + + return "", fmt.Errorf("unable to find zone") +} + +// icmpTypeTable selects the IPv4 or IPv6 name/number table by family. +func (f *FirewallD) icmpTypeTable(isV6 bool) map[string]uint8 { + if isV6 { + return fwICMPv6Types + } + return fwICMPv4Types +} + +// icmpTypeNumber returns the numeric ICMP type for a firewalld icmp-type name in +// the given family, and whether the name is known. +func (f *FirewallD) icmpTypeNumber(isV6 bool, name string) (uint8, bool) { + n, ok := f.icmpTypeTable(isV6)[strings.ToLower(name)] + return n, ok +} + +// splitRichRuleFields tokenizes a firewalld rich rule on whitespace while +// keeping a double-quoted value as a single token, quotes included (so the +// existing trimQuotes callers still work). firewalld quotes rich-rule attribute +// values, and some — a log prefix, an address — legitimately contain spaces; +// plain strings.Fields would split those and break the parse. +func (f *FirewallD) splitRichRuleFields(s string) []string { + var tokens []string + var b strings.Builder + inQuote := false + flush := func() { + if b.Len() > 0 { + tokens = append(tokens, b.String()) + b.Reset() + } + } + for _, r := range s { + switch { + case r == '"': + inQuote = !inQuote + b.WriteRune(r) + case (r == ' ' || r == '\t') && !inQuote: + flush() + default: + b.WriteRune(r) + } + } + flush() + return tokens +} + +// UnmarshalRichRule takes a rich-rule string and returns a parsed rule for supported rules. +func (f *FirewallD) UnmarshalRichRule(richRule string) (r *Rule, err error) { + // Setup new rule. + r = new(Rule) + + // Get tokens for rule. splitRichRuleFields keeps a quoted value with spaces + // as a single token (see its doc). + tokens := f.splitRichRuleFields(richRule) + if len(tokens) == 0 { + return nil, fmt.Errorf("empty rule") + } + + // Confirm this is a rich rule. + if tokens[0] != "rule" { + return nil, fmt.Errorf("invalid rule format") + } + + // Process the rule. + for i := 1; i < len(tokens); i++ { + // Check the token type and parse. + if strings.HasPrefix(tokens[i], "family=") { + // Family can only be IPv4 or IPv6. + family := trimQuotes(strings.TrimPrefix(tokens[i], "family=")) + if strings.EqualFold(family, "ipv4") { + r.Family = IPv4 + } else if strings.EqualFold(family, "ipv6") { + r.Family = IPv6 + } else { + return nil, fmt.Errorf("invalid family value") + } + } else if strings.HasPrefix(tokens[i], "priority=") { + // Parse the priority int. + priority := trimQuotes(strings.TrimPrefix(tokens[i], "priority=")) + p, err := strconv.Atoi(priority) + if err != nil { + return nil, err + } + r.Priority = p + } else if tokens[i] == "source" { + // The source must contain at least one value after. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing source value") + } + + // It is possible to define as a NOT match by adding NOT. + not := false + if strings.EqualFold(tokens[i], "NOT") { + not = true + + // Check that there is a source defined after the not. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing source value") + } + } + + // Check the source value, to parse out the type. + source := tokens[i] + if strings.HasPrefix(source, "address=") { + address := trimQuotes(strings.TrimPrefix(source, "address=")) + if not { + r.Source = "!" + address + } else { + r.Source = address + } + } else if strings.HasPrefix(source, "mac=") { + mac := trimQuotes(strings.TrimPrefix(source, "mac=")) + if not { + r.Source = "!" + mac + } else { + r.Source = mac + } + } else if strings.HasPrefix(source, "ipset=") { + ipset := trimQuotes(strings.TrimPrefix(source, "ipset=")) + if not { + r.Source = "!" + ipset + } else { + r.Source = ipset + } + } else { // If the source is not defined on a none key=value, source is invalid. + return nil, fmt.Errorf("the source argument has no type defined") + } + } else if tokens[i] == "destination" { + // The destination must contain at least one value after. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing destination value") + } + + // It is possible to define as a NOT match by adding NOT. + not := false + if strings.EqualFold(tokens[i], "NOT") { + not = true + + // Check that there is a destination defined after the not. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing destination value") + } + } + + // Parse the destination, which firewalld expresses as either an address + // or an ipset (mirroring the source grammar). firewalld has no output + // chain, so a destination element is an inbound-frame destination match, + // not a direction signal — the write side stores every rule in the + // inbound frame (see AddRule). + if strings.HasPrefix(tokens[i], "address=") { + address := trimQuotes(strings.TrimPrefix(tokens[i], "address=")) + if not { + r.Destination = "!" + address + } else { + r.Destination = address + } + } else if strings.HasPrefix(tokens[i], "ipset=") { + ipset := trimQuotes(strings.TrimPrefix(tokens[i], "ipset=")) + if not { + r.Destination = "!" + ipset + } else { + r.Destination = ipset + } + } else { + return nil, fmt.Errorf("the destination argument has no address or ipset defined") + } + } else if tokens[i] == "log" { + // Record that the rule logs, and capture the optional prefix. Any + // key=value qualifiers that follow (prefix="...", level="...") are + // consumed here; only the prefix is stored, the rest are ignored. + r.Log = true + for i+1 < len(tokens) && strings.Contains(tokens[i+1], "=") { + q := tokens[i+1] + if strings.HasPrefix(q, "prefix=") { + r.LogPrefix = trimQuotes(strings.TrimPrefix(q, "prefix=")) + } + i++ + } + // A limit element directly after log throttles only the logging, which + // RateLimit cannot hold — it models a traffic limit. Reading it as one + // would let a rewrite turn a log throttle into a traffic throttle, so + // the rule stays unmodeled. + if i+1 < len(tokens) && tokens[i+1] == "limit" { + return nil, fmt.Errorf("a log-scoped limit is unsupported") + } + } else if tokens[i] == "limit" { + // A rule-level rate limit: limit value="N/unit" where unit is one of + // s/m/h/d. Parse it into the rule's RateLimit. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing limit value") + } + if !strings.HasPrefix(tokens[i], "value=") { + return nil, fmt.Errorf("the limit element has no value") + } + val := trimQuotes(strings.TrimPrefix(tokens[i], "value=")) + num, unitStr, ok := strings.Cut(val, "/") + if !ok { + return nil, fmt.Errorf("invalid limit value %q", val) + } + n, err := strconv.ParseUint(strings.TrimSpace(num), 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid limit value %q", val) + } + unit, err := ParseRateUnit(unitStr) + if err != nil { + return nil, err + } + r.RateLimit = &RateLimit{Rate: uint(n), Unit: unit} + } else if tokens[i] == "audit" { + // Ignore audit element. + } else if tokens[i] == "port" { + // The port must contain the port and protocol definitions. + if i+2 >= len(tokens) { + return nil, fmt.Errorf("missing port parameters") + } + i++ + + // Parse the port parameter, which may be a single port or a dash + // range such as "1000-2000". + if strings.HasPrefix(tokens[i], "port=") { + port := trimQuotes(strings.TrimPrefix(tokens[i], "port=")) + pr, err := ParsePortRange(port) + if err != nil { + return nil, fmt.Errorf("the port argument %s is invalid", tokens[i]) + } + if pr.Start == pr.End { + r.Port = pr.Start + } else { + r.Ports = []PortRange{pr} + } + } else { + return nil, fmt.Errorf("the port element has no defined port") + } + i++ + + // Parse the protocol. + if strings.HasPrefix(tokens[i], "protocol=") { + proto := trimQuotes(strings.TrimPrefix(tokens[i], "protocol=")) + r.Proto = GetProtocol(proto) + } else { + return nil, fmt.Errorf("the port element has no defined protocol") + } + // firewalld allows a port on a protocol this library cannot model + // (e.g. dccp, which GetProtocol widens to ProtocolAny) or on a modeled + // but portless protocol. Such a rule cannot round-trip — MarshalRichRule + // rejects a port without a concrete tcp/udp/sctp protocol — so reject it + // here, matching the zone-port path, so GetRules skips it rather than + // surfacing a rule Restore could never re-add. + if !r.Proto.HasPorts() { + return nil, fmt.Errorf("the port element uses a protocol that cannot carry a port") + } + } else if tokens[i] == "source-port" { + // A source-port element mirrors the port element but matches the + // packet's source port: source-port port="1024" protocol="tcp". + if i+2 >= len(tokens) { + return nil, fmt.Errorf("missing source-port parameters") + } + i++ + + // Parse the source port, which may be a single port or a dash range. + if strings.HasPrefix(tokens[i], "port=") { + port := trimQuotes(strings.TrimPrefix(tokens[i], "port=")) + pr, err := ParsePortRange(port) + if err != nil { + return nil, fmt.Errorf("the source-port argument %s is invalid", tokens[i]) + } + if pr.Start == pr.End { + r.SourcePort = pr.Start + } else { + r.SourcePorts = []PortRange{pr} + } + } else { + return nil, fmt.Errorf("the source-port element has no defined port") + } + i++ + + // Parse the protocol. + if strings.HasPrefix(tokens[i], "protocol=") { + proto := trimQuotes(strings.TrimPrefix(tokens[i], "protocol=")) + r.Proto = GetProtocol(proto) + } else { + return nil, fmt.Errorf("the source-port element has no defined protocol") + } + // See the port element above: a source-port on a protocol that cannot + // carry a port cannot round-trip, so reject rather than surface it. + if !r.Proto.HasPorts() { + return nil, fmt.Errorf("the source-port element uses a protocol that cannot carry a port") + } + } else if tokens[i] == "protocol" { + // A bare protocol element (no port), e.g. an ICMP match: + // protocol value="icmp" / value="ipv6-icmp". + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing protocol value") + } + if !strings.HasPrefix(tokens[i], "value=") { + return nil, fmt.Errorf("the protocol element has no defined value") + } + proto := GetProtocol(trimQuotes(strings.TrimPrefix(tokens[i], "value="))) + if proto == ProtocolAny { + return nil, fmt.Errorf("unsupported protocol value") + } + r.Proto = proto + } else if tokens[i] == "icmp-type" { + // An icmp-type element restricts to a single ICMP message type by + // firewalld name, e.g. icmp-type name="echo-request". The numeric type + // and the ICMP protocol both depend on the rule's family, which appears + // earlier in the rule string, so r.Family is already set here. A + // familyless icmp-type rule (foreign; firewalld applies it to both + // families) has no single ICMP/ICMPv6 form, so it stays unmodeled + // rather than reading back as its IPv4 half. + i++ + if i >= len(tokens) || !strings.HasPrefix(tokens[i], "name=") { + return nil, fmt.Errorf("the icmp-type element has no defined name") + } + if r.Family == FamilyAny { + return nil, fmt.Errorf("an icmp-type element without a family is unsupported") + } + isV6 := r.Family == IPv6 + num, ok := f.icmpTypeNumber(isV6, trimQuotes(strings.TrimPrefix(tokens[i], "name="))) + if !ok { + return nil, fmt.Errorf("unsupported icmp-type name") + } + r.ICMPType = Ptr(num) + if isV6 { + r.Proto = ICMPv6 + } else { + r.Proto = ICMP + } + } else if tokens[i] == "accept" { + r.Action = Accept + } else if tokens[i] == "reject" { + r.Action = Reject + + // Ignore type definition for reject. + if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "type=") { + i++ + } + } else if tokens[i] == "drop" { + r.Action = Drop + } else { + return nil, fmt.Errorf("the element %s is unsupported", tokens[i]) + } + } + + // If no action provided, error. + if r.Action == ActionInvalid { + return nil, fmt.Errorf("no valid action was provided") + } + + // Return the parsed rule. + return +} + +// resolveZoneName substitutes the default zone when zoneName is empty. The rest +// of go-firewall treats an empty zone as "the default" (zoneless backends ignore +// it entirely), but firewalld's permanent config interface rejects an empty zone +// name with INVALID_ZONE, so every zone-scoped method resolves it here first. +func (f *FirewallD) resolveZoneName(ctx context.Context, zoneName string) (string, error) { + if zoneName != "" { + return zoneName, nil + } + return f.Conn.DefaultZone(ctx) +} + +// zonePortRules maps a firewalld zone port list (settings.Ports or SourcePorts) +// to allow rules, one per entry. source selects whether the range binds to the +// source-port or destination-port fields. A port on an unmodeled protocol (e.g. +// dccp, which GetProtocol maps to ProtocolAny) is skipped: it has no expressible +// Rule, so surfacing it would leave a rule RemoveRule and MarshalRichRule reject. +// This mirrors the protocols loop's guard. +func (f *FirewallD) zonePortRules(ports []firewalld.Port, source bool) []*Rule { + var rules []*Rule + for _, port := range ports { + pr, perr := ParsePortRange(port.Port) + if perr != nil { + continue + } + proto := GetProtocol(port.Protocol) + if !proto.HasPorts() { + continue + } + rule := &Rule{Proto: proto, Action: Accept} + switch { + case source && pr.Start == pr.End: + rule.SourcePort = pr.Start + case source: + rule.SourcePorts = []PortRange{pr} + case pr.Start == pr.End: + rule.Port = pr.Start + default: + rule.Ports = []PortRange{pr} + } + rules = append(rules, rule) + } + return rules +} + +// GetRules returns the filter rules for a zone, resolving an empty zone to the default. +func (f *FirewallD) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + zoneName, err = f.resolveZoneName(ctx, zoneName) + if err != nil { + return + } + + // Get the zone settings. + settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx) + if err != nil { + return + } + + // Named services (settings.Services) have no Rule representation and are + // intentionally not surfaced here; only ports, source ports, sources and + // rich rules map to managed rules. + + // Add port allows to rule list. A zone port entry may be a single port or a + // contiguous range (e.g. "49152-49215"), so parse it as a range and collapse + // a single-port range back onto the scalar Port field. + rules = append(rules, f.zonePortRules(settings.Ports, false)...) + + // Add bare-protocol allows (firewall-cmd --add-protocol) to the rule list. + // firewalld stores these as a zone protocol entry rather than a rich rule, so + // surface each recognized one as a portless-protocol rule; otherwise it is + // invisible to Sync/Restore and can never be reconciled. An unrecognized + // protocol has no Rule representation and is left unmanaged. + for _, proto := range settings.Protocols { + if p := GetProtocol(proto); p != ProtocolAny { + rules = append(rules, &Rule{Proto: p, Action: Accept}) + } + } + + // Add source-port allows to rule list, likewise reading a single port or a + // contiguous range. + rules = append(rules, f.zonePortRules(settings.SourcePorts, true)...) + + // Add source allows to rule list. + for _, source := range settings.Sources { + rule := &Rule{ + Source: source, + Action: Accept, + } + rules = append(rules, rule) + } + + // Parse and add rich rules. + for _, richRule := range settings.RichRules { + rule, err := f.UnmarshalRichRule(richRule) + if err != nil { + continue + } + rules = append(rules, rule) + } + + // Every entry above is reported as firewalld stores it. A rich rule with no + // `family=` attribute, and a zone port or source, cover both IP families as one + // object and read back as FamilyAny on their own; a rich rule's port element + // carries exactly one protocol, so a both-transports rule is two entries and is + // reported as two rules. + + // firewalld isolates rules by zone; this read is already scoped to a single + // zone, so every rule read here lives in zoneName — record the zone and flag it + // as carrying the prefix. + for _, r := range rules { + r.table = zoneName + r.HasPrefix = true + } + return +} + +// icmpTypeName returns the firewalld icmp-type name for a numeric ICMP type in +// the given family, and whether the type is expressible as a rich rule element. +func (f *FirewallD) icmpTypeName(isV6 bool, typ uint8) (string, bool) { + for name, n := range f.icmpTypeTable(isV6) { + if n == typ { + return name, true + } + } + return "", false +} + +// protoValue returns the protocol name firewalld's `protocol value=` element +// expects. ICMPv6 is named `ipv6-icmp` in /etc/protocols. +func (f *FirewallD) protoValue(p Protocol) string { + if p == ICMPv6 { + return "ipv6-icmp" + } + return p.String() +} + +// rateUnit maps a RateUnit to the single-letter time unit a firewalld rich +// rule's `limit value="N/unit"` expects (s/m/h/d). +func (f *FirewallD) rateUnit(u RateUnit) string { + switch u { + case PerMinute: + return "m" + case PerHour: + return "h" + case PerDay: + return "d" + } + return "s" +} + +// MarshalRichRule encodes a rule into a firewalld rich-rule string. +func (f *FirewallD) MarshalRichRule(r *Rule) (richRule string, err error) { + // firewalld's zone/rich-rule model has no forward chain, so a forward rule + // cannot be expressed. + if r.IsForward() { + return "", unsupportedForward("firewalld") + } + // A port in a rich rule requires a concrete protocol; `protocol="any"` is + // not valid, so reject rather than emit a rule firewalld will refuse. + if r.PortNeedsConcreteProtocol() { + return "", fmt.Errorf("a port requires a tcp, udp or sctp protocol") + } + + // Features a rich rule cannot express are rejected rather than dropped. A + // rich rule has no conntrack-state or per-rule interface match (interfaces + // bind to zones instead). A port list has no single rich-rule form, but + // AddRule/RemoveRule fan one into a row per spec with expandPorts before this + // row-level marshaller runs, so only a single spec per axis ever reaches here. + if r.State != 0 { + return "", fmt.Errorf("firewalld does not support connection-state matching in a rich rule: %w", ErrUnsupportedState) + } + if r.InInterface != "" || r.OutInterface != "" { + return "", fmt.Errorf("firewalld binds interfaces to zones, not individual rules: %w", ErrUnsupportedInterface) + } + // A rich rule carries only one port element, so a destination port and a + // source port cannot be matched by the same rule. + if len(r.PortSpecs()) == 1 && len(r.SourcePortSpecs()) == 1 { + return "", fmt.Errorf("firewalld rich rules cannot match a destination and source port together: %w", ErrUnsupportedSourcePort) + } + // An ICMP type only applies to an ICMP/ICMPv6 protocol; reject it paired with + // anything else before rendering an icmp-type element. + if err := r.checkICMPType(); err != nil { + return "", err + } + // A rich rule can log and rate-limit, but has no way to express a + // connection-count limit. + if r.ConnLimit != nil { + return "", fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit) + } + // A rich rule limit is a bare rate (value="N/unit") with no burst allowance, + // so a specific non-zero burst cannot be expressed and is rejected rather than + // silently dropped. The netfilter default burst (5) is treated as "unset" + // everywhere else in the library (normBurst/eqRateLimit), so a rule carrying + // Burst=5 normalizes to 0 and round-trips cleanly here — it must be accepted, + // keeping a desired set portable across backends. + if r.RateLimit != nil && normBurst(r.RateLimit.Burst) != 0 { + return "", fmt.Errorf("firewalld does not support a rate-limit burst in a rich rule: %w", ErrUnsupportedRateLimit) + } + + // Start with the base rule. + parts := []string{"rule"} + + // Add priority. + if r.Priority != 0 { + parts = append(parts, fmt.Sprintf(`priority="%d"`, r.Priority)) + } + + // Add family. firewalld requires a family whenever a rich rule matches an IP + // address, and stores the rule under that concrete family, so a FamilyAny rule + // carrying a source or destination is qualified with that address's family + // rather than emitting a familyless — and, with an address, invalid — rule. A + // bare (untyped) ICMP/ICMPv6 protocol match needs no such qualification: + // firewalld accepts a familyless `protocol value="ipv6-icmp"` rule just like + // any other protocol (it only requires a family for a source/destination + // address, never for a protocol), and this library's own read path recovers + // ICMP vs ICMPv6 directly from the protocol value string regardless of + // family. A *typed* + // ICMP match (icmp-type name=...) is different: firewalld resolves the type + // name without a protocol element (see the icmp-type element emitted below), so the emitted + // family is this library's own disambiguator on read — ICMPv4 and ICMPv6 + // reuse several of the same type names for different numbers — not something + // firewalld itself requires. + fam := r.Family + if fam == FamilyAny { + switch { + case r.Proto.IsICMP() && r.ICMPType != nil: + if r.Proto == ICMPv6 { + fam = IPv6 + } else { + fam = IPv4 + } + default: + if af := familyOfAddr(r.Source); af != FamilyAny { + fam = af + } else if af := familyOfAddr(r.Destination); af != FamilyAny { + fam = af + } + } + } + if fam != FamilyAny { + parts = append(parts, fmt.Sprintf(`family="%s"`, fam.String())) + } else if dst := strings.TrimPrefix(r.Destination, "!"); dst != "" { + // firewalld requires an explicit family for `destination ipset="..."` + // (unlike `source ipset="..."`, which it accepts familyless) since an + // ipset's members could be either family. familyOfAddr above already + // resolves a destination CIDR/IP; this only fires when the destination + // is an ipset name and the caller left Family unset, in which case there + // is no address to infer a family from — reject rather than emit a rule + // firewalld refuses with MISSING_FAMILY. + if _, _, err := net.ParseCIDR(dst); err != nil && net.ParseIP(dst) == nil { + return "", fmt.Errorf("firewalld requires an explicit Family for a destination ipset match: %w", ErrUnsupportedSet) + } + } + + // Add source. + if r.Source != "" { + parts = append(parts, "source") + + // If not defined, append NOT. + src := r.Source + if src[0] == '!' { + parts = append(parts, "NOT") + src = src[1:] + } + + // Check if CIDR. + _, _, err := net.ParseCIDR(src) + ip := net.ParseIP(src) + if err == nil || ip != nil { + parts = append(parts, fmt.Sprintf(`address="%s"`, src)) + } else { + // Check if MAC. + _, err := net.ParseMAC(src) + if err == nil { + parts = append(parts, fmt.Sprintf(`mac="%s"`, src)) + } else { + parts = append(parts, fmt.Sprintf(`ipset="%s"`, src)) + } + } + } + + // Add destination. + if r.Destination != "" { + parts = append(parts, "destination") + + // If not defined, append NOT. + dst := r.Destination + if dst[0] == '!' { + parts = append(parts, "NOT") + dst = dst[1:] + } + + // Check if CIDR or IP; otherwise it is an ipset name, which firewalld's + // destination grammar accepts (`destination ipset="..."`) like the source. + _, _, err := net.ParseCIDR(dst) + ip := net.ParseIP(dst) + if err == nil || ip != nil { + parts = append(parts, fmt.Sprintf(`address="%s"`, dst)) + } else { + parts = append(parts, fmt.Sprintf(`ipset="%s"`, dst)) + } + } + + // An ICMP/ICMPv6 rule matches by protocol value, or — when a specific type is + // requested — by an icmp-type element. firewalld resolves the type name against + // the rule's family; the ICMP protocol pins it (ICMP => IPv4, ICMPv6 => IPv6), + // so the variant is derived from the protocol rather than requiring the caller + // to set Family (the rule is already qualified with that family above). + if r.Proto.IsICMP() { + if r.ICMPType != nil { + isV6 := r.Proto == ICMPv6 + name, ok := f.icmpTypeName(isV6, *r.ICMPType) + if !ok { + return "", fmt.Errorf("firewalld cannot express icmp type %d for %s: %w", *r.ICMPType, fam.String(), ErrUnsupported) + } + parts = append(parts, "icmp-type", fmt.Sprintf(`name="%s"`, name)) + } else { + parts = append(parts, "protocol", fmt.Sprintf(`value="%s"`, f.protoValue(r.Proto))) + } + } else if specs := r.PortSpecs(); len(specs) == 1 { + // A single port or dash range, e.g. port="23" or port="1000-2000". + parts = append(parts, "port", fmt.Sprintf(`port="%s"`, specs[0].String()), fmt.Sprintf(`protocol="%s"`, r.Proto.String())) + } else if specs := r.SourcePortSpecs(); len(specs) == 1 { + // A source-port match, e.g. source-port port="1024" protocol="tcp". + // Emitted only when there is no destination port, since a rich rule + // carries a single port element. + parts = append(parts, "source-port", fmt.Sprintf(`port="%s"`, specs[0].String()), fmt.Sprintf(`protocol="%s"`, r.Proto.String())) + } else if r.Proto != ProtocolAny { + // A bare protocol match with no port, such as GRE, ESP or AH (or a + // portless tcp/udp/sctp rule): protocol value="gre". + parts = append(parts, "protocol", fmt.Sprintf(`value="%s"`, f.protoValue(r.Proto))) + } + + // A log element attaches to the whole rule, immediately before the action: + // rule ... [log] action. + if r.Log { + if r.LogPrefix != "" { + parts = append(parts, "log", fmt.Sprintf(`prefix="%s"`, r.LogPrefix), `level="info"`) + } else { + parts = append(parts, "log", `level="info"`) + } + } + + // Add the action, then any rate limit. In firewalld's rich-rule grammar a + // limit is not a standalone element: it is a trailing attribute of the + // action (or of log/audit). Emitting it before the action produces a rule + // firewalld rejects, or — after a log element — one that rate-limits the + // logging rather than the packet. Attaching it to the action rate-limits the + // action, matching the RateLimit semantics on the other backends. + parts = append(parts, r.Action.String()) + if r.RateLimit != nil { + parts = append(parts, "limit", fmt.Sprintf(`value="%d/%s"`, r.RateLimit.Rate, f.rateUnit(r.RateLimit.Unit))) + } + + // Return the built parts joined with spaces. + return strings.Join(parts, " "), nil +} + +// ignoreAlreadyEnabled treats firewalld's ALREADY_ENABLED as success, making an +// add idempotent: re-adding an element that is already present is a no-op. +func (f *FirewallD) ignoreAlreadyEnabled(err error) error { + if errors.Is(err, firewalld.ErrAlreadyEnabled) { + return nil + } + return err +} + +// sourceZoneShape reports whether a rule's non-address shape lets a plain source +// map to a firewalld zone source: no protocol match, no destination port, no +// source port, and a non-negated source. A source combined with a concrete +// protocol or a port is a rich rule (firewalld encodes those as +// `source address="..." protocol value="..."`/`port ...`), so it is excluded here +// — encoding such a rule as a bare zone source would silently drop the protocol or +// port match and widen it. AddRule and RemoveRule share this so their zone-source +// routing stays symmetric; they differ only in which source *forms* they accept (a +// MAC source is added as a rich rule but removed via the zone-source path). +func (f *FirewallD) sourceZoneShape(r *Rule) bool { + return r.Source != "" && r.Source[0] != '!' && r.Proto == ProtocolAny && + !r.HasPorts() && !r.HasSourcePorts() +} + +// zoneEntryEligible reports whether a rule can be expressed as a firewalld +// zone-level entry (a zone port, source port, or source) rather than a rich +// rule. firewalld stores a zone port/source-port as a single port OR a single +// contiguous range, so a lone range is eligible; only a genuine multi-element +// port list (or any of the rich-only features — a concrete family/priority, a +// destination, state/interface/ICMP matching, logging or rate limiting) forces +// the rich-rule path. Eligibility keys on the port-list length rather than +// HasPortSet, which is also true for a single range and would misroute a +// zone-port range onto the rich-rule path. +func (f *FirewallD) zoneEntryEligible(r *Rule) bool { + return r.Action == Accept && r.Family == FamilyAny && r.Priority == 0 && r.Destination == "" && + r.State == 0 && r.InInterface == "" && r.OutInterface == "" && !r.Proto.IsICMP() && + len(r.PortSpecs()) <= 1 && len(r.SourcePortSpecs()) <= 1 && + !r.Log && r.RateLimit == nil +} + +// resolveDestSetFamily pins a family-agnostic rule whose destination names an +// ipset to the set's own family: firewalld requires an explicit family for a +// destination ipset match (unlike a source one, which it accepts familyless), +// and an ipset is family-typed, so the rule could only ever match that family. +// Rules pinned already, or carrying an address that implies a family, pass +// through unchanged. +func (f *FirewallD) resolveDestSetFamily(ctx context.Context, r *Rule) (*Rule, error) { + if r.Family != FamilyAny || !isSetRef(r.Destination) { + return r, nil + } + if familyOfAddr(r.Source) != FamilyAny { + return r, nil + } + fam, err := setRefFamilyFrom(func(name string) (Family, bool, error) { + set, gerr := f.getAddressSet(ctx, name) + if gerr != nil { + return FamilyAny, false, gerr + } + if set == nil { + return FamilyAny, false, nil + } + return set.Family, true, nil + }, "", r.Destination) + if err != nil { + return nil, err + } + rc := *r + rc.Family = fam + return &rc, nil +} + +// AddRule adds a filter rule to a zone, using a zone-level entry when the rule +// fits one and a rich rule otherwise. +func (f *FirewallD) AddRule(ctx context.Context, zoneName string, r *Rule) error { + if err := r.validate(); err != nil { + return err + } + + // firewalld has no output chain (Capabilities().Output is false); it models a + // "destination" match rather than a true outbound direction. A both-directions + // DirAny rule cannot be expressed, so it degrades to its input half, and a + // DirOutput rule is stored in the inbound frame (role-swapped) so both + // authorings of the same match converge on one stored form and read back + // identically — otherwise Sync would remove and re-add it forever. + r = dirAnyInputFallback(r, f.Capabilities().Output) + if r.Direction == DirOutput { + r = r.canonicalMatch() + } + + // A firewalld zone port and rich rule each carry a single protocol, so a + // TCPUDP rule has no single form; fan it into a tcp add and a udp add, each of + // which routes through the zone-port or rich-rule path below on its own. + if subs := expandProtocols(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // A zone port and a rich-rule port element each carry a single port or range, + // never a list, so a multi-port rule fans into one add per port spec — the + // port analog of the transport fan-out above. A rule carrying a list on both + // port axes fans into source+destination pairs, which the rich-rule path then + // rejects (PortPair is false). + if subs := expandPorts(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // A connection-count limit cannot be expressed in a firewalld rich rule. + if r.ConnLimit != nil { + return fmt.Errorf("firewalld does not support connection limiting: %w", ErrUnsupportedConnLimit) + } + + // A family-agnostic destination set reference is pinned to the set's own family. + r, err := f.resolveDestSetFamily(ctx, r) + if err != nil { + return err + } + + // Get the zone. + zoneName, err = f.resolveZoneName(ctx, zoneName) + if err != nil { + return err + } + zone := f.Conn.Permanent().Zone(zoneName) + + // Check if rule may be a non-rich rule. Basically only accept actions. Any + // of the newer match features force the rich-rule path so they are handled + // (ICMP) or rejected there rather than silently dropped by the port/source + // shortcuts below. Logging and rate limiting also force the rich-rule path + // because the AddPort/AddSource shortcuts cannot carry them. A single + // destination port, a single source port, or a source address each map to a + // zone-level entry; anything more (a set, or both port dimensions together) + // falls through to the rich-rule path. + if f.zoneEntryEligible(r) { + // A single destination port (or contiguous range) with no source match maps + // to a zone port. Test HasPorts (not r.Port), so a single port carried in the + // Ports slice takes this path too rather than falling through to the source + // shortcut and losing the port match. A concrete tcp/udp/sctp protocol is + // required; otherwise fall through to the rich-rule path for its clean + // rejection instead of asking firewalld for a protocol="any" port. + if r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts() { + return f.ignoreAlreadyEnabled(zone.AddPort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()})) + } + // A single source port with no destination port and no source address + // maps to a zone source port. + if r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "" { + return f.ignoreAlreadyEnabled(zone.AddSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()})) + } + // A bare source address maps to a zone source: an IP, a subnet, or an ipset + // reference (ipset:). GetRules reports a zone ipset source in that exact + // `ipset:` form and RemoveRule clears it via isZoneSource, so AddRule + // must add it the same way — otherwise it falls through to the rich-rule path, + // which bakes the `ipset:` prefix into the ipset name (source ipset="ipset:name") + // and references a nonexistent ipset. (A MAC source stays on the rich-rule path, + // where it round-trips as source mac="...".) + // A zone source encodes only the address, so it must not carry a concrete + // protocol: a source+protocol rule (e.g. tcp from 1.2.3.4) belongs on the + // rich-rule path (see sourceZoneShape). Taking AddSource here would silently + // drop the protocol and widen the rule to every protocol, and GetRules would + // read it back as ProtocolAny. + if f.sourceZoneShape(r) { + _, _, cidrErr := net.ParseCIDR(r.Source) + ip := net.ParseIP(r.Source) + if cidrErr == nil || ip != nil || strings.HasPrefix(r.Source, "ipset:") { + return f.ignoreAlreadyEnabled(zone.AddSource(ctx, r.Source)) + } + } + } + + // Encode the rich rule and add it. + richRule, err := f.MarshalRichRule(r) + if err != nil { + return err + } + return f.ignoreAlreadyEnabled(zone.AddRichRule(ctx, richRule)) +} + +// InsertRule is unsupported: firewalld rich rules and port/source shortcuts are +// not positionally ordered. +func (f *FirewallD) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + return unsupportedOrdering(f.Type()) +} + +// MoveRule is unsupported for the same reason as InsertRule. +func (f *FirewallD) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + return unsupportedOrdering(f.Type()) +} + +// removeZoneEntry removes the zone-level entry — a zone port, source port, or +// protocol — that a zone-entry-shaped rule maps to, reporting whether the rule's +// shape matched one of those forms. The firewalld error (including NOT_ENABLED) +// is returned uninterpreted; RemoveRule's zone-entry shortcut and its dual-stack +// split share this so their form routing cannot drift apart. +func (f *FirewallD) removeZoneEntry(ctx context.Context, zone *firewalld.PermZone, r *Rule) (bool, error) { + switch { + case r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts(): + return true, zone.RemovePort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()}) + case r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "": + return true, zone.RemoveSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()}) + case r.Proto != ProtocolAny && r.Source == "" && !r.HasPorts() && !r.HasSourcePorts(): + return true, zone.RemoveProtocol(ctx, r.Proto.String()) + } + return false, nil +} + +// isZoneSource reports whether a source string is a form firewalld stores as a +// zone source: an IP, a CIDR, a MAC address, or an ipset reference (ipset:). +// RemoveRule uses it to decide whether a bare source can be cleared with +// RemoveSource rather than falling through to the rich-rule path. +func (f *FirewallD) isZoneSource(s string) bool { + if _, _, err := net.ParseCIDR(s); err == nil { + return true + } + if net.ParseIP(s) != nil { + return true + } + if strings.HasPrefix(s, "ipset:") { + return true + } + if _, err := net.ParseMAC(s); err == nil { + return true + } + return false +} + +// ignoreNotEnabled treats firewalld's NOT_ENABLED as success, making a remove +// idempotent: removing an element that is not present is a no-op. +func (f *FirewallD) ignoreNotEnabled(err error) error { + if errors.Is(err, firewalld.ErrNotEnabled) { + return nil + } + return err +} + +// RemoveRule removes a filter rule from a zone, clearing every form it may be +// stored in: the zone-level entry AddRule would have used and any rich rule +// whose parsed form matches the target. +func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + if err := r.validate(); err != nil { + return err + } + + // A DirAny rule degrades to its input half on firewalld (no output concept), + // and a DirOutput target is role-swapped into the inbound frame, mirroring + // AddRule so a rule is found and removed as stored. + r = dirAnyInputFallback(r, f.Capabilities().Output) + if r.Direction == DirOutput { + r = r.canonicalMatch() + } + + // firewalld stores a TCPUDP rule as two concrete rows (a tcp entry and a udp + // entry), never one shared row, so such a removal fans into a tcp remove and a + // udp remove — the exact inverse of AddRule's fan-out. Removing a concrete + // transport still deletes only its own row, so no protocol-axis split is needed. + if subs := expandProtocols(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // A multi-port rule is stored as one row per port spec (AddRule's fan-out), + // never one shared row, so such a removal fans into a single-port remove per + // spec — the inverse of the add fan-out. Removing one port of a list still + // deletes only its own row, so no port-axis split is needed. + if subs := expandPorts(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // Get the zone. + zoneName, err := f.resolveZoneName(ctx, zoneName) + if err != nil { + return err + } + zone := f.Conn.Permanent().Zone(zoneName) + + // Check if rule maps to a zone element rather than a rich rule, mirroring how + // AddRule stores it. Any of the newer match features force the rich-rule path + // so they are handled (ICMP) or rejected there rather than silently dropped by + // the port/source shortcuts below. Logging and rate limiting also force the + // rich-rule path. Each shortcut removes the element directly and relies on + // firewalld's typed errors: a real error is returned, while success and + // NOT_ENABLED both continue to the rich-rule sweep — a rule present in both + // storage forms (a zone entry plus an equivalent rich rule added out of band) + // must be cleared from both in one call. + if f.zoneEntryEligible(r) { + if matched, rerr := f.removeZoneEntry(ctx, zone, r); matched { + if rerr != nil && !errors.Is(rerr, firewalld.ErrNotEnabled) { + return rerr + } + } else if f.sourceZoneShape(r) && f.isZoneSource(r.Source) { + // A bare source maps to a zone source. firewalld stores an IP/CIDR, a MAC + // address, or an ipset reference (ipset:) as a zone source, and + // GetRules reports each verbatim, so removal must try RemoveSource for all + // of them. A value that is not a zone source form is left to the rich-rule + // path below; sourceZoneShape mirrors AddRule's ProtocolAny guard, since a + // source+protocol rule is stored as a rich rule. + if err := zone.RemoveSource(ctx, r.Source); err != nil && !errors.Is(err, firewalld.ErrNotEnabled) { + return err + } + } + } + + // Split a dual-stack zone entry on a concrete-family removal. firewalld zone + // ports, source ports, and protocols carry no family, so a FamilyAny rule of + // those shapes is stored as one shared entry; zoneEntryEligible (and the + // shortcuts above) require FamilyAny, so a concrete-family target skips them and + // would otherwise no-op — leaving both families in place. Remove the shared + // entry, then re-add the untargeted family, which — being concrete — becomes a + // rich rule, so its coverage survives. A NOT_ENABLED result means the rule was + // not stored as that zone entry, so fall through to the rich-rule path (which + // runs its own split). A bare zone source is excluded: an address carries its own + // family, so there is no dual-stack entry to split. + if fam := r.impliedFamily(); fam != FamilyAny { + rAny := *r + rAny.Family = FamilyAny + if f.zoneEntryEligible(&rAny) && r.Source == "" { + if matched, removeErr := f.removeZoneEntry(ctx, zone, r); matched { + if removeErr == nil { + if s := splitDualRow(&rAny, r); s != nil { + return f.AddRule(ctx, zoneName, s) + } + return nil + } + if !errors.Is(removeErr, firewalld.ErrNotEnabled) { + return removeErr + } + } + } + } + + // A foreign bare ICMP/ICMPv6 protocol allow (firewall-cmd --add-protocol=icmp) + // is surfaced by GetRules as a portless ICMP-protocol rule, but ICMP is excluded + // from zoneEntryEligible (the library writes its own ICMP matches as rich + // rules), so the zone-protocol shortcut above never runs for it. Attempt the + // zone-protocol removal here for exactly that bare shape; a NOT_ENABLED result + // falls through to the rich-rule path for an ICMP match the library stored as a + // rich rule instead. + if r.Proto.IsICMP() && r.ICMPType == nil && r.Action == Accept && r.Source == "" && + r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && r.State == 0 && + r.InInterface == "" && r.OutInterface == "" && !r.Log && r.RateLimit == nil && r.Priority == 0 { + proto := "icmp" + if r.Proto == ICMPv6 { + proto = "ipv6-icmp" + } + if err := zone.RemoveProtocol(ctx, proto); err != nil && !errors.Is(err, firewalld.ErrNotEnabled) { + return err + } + } + + // Rich-rule path: read the zone settings and remove every stored rich rule + // whose parsed form matches the target, so a rule firewalld stored with + // different formatting than our marshaller produces is still matched. Match with + // EqualForRemoval rather than the family-strict Equal: a FamilyAny target must + // clear both the familyless rich rule it names and any family-pinned rich rules + // it covers, while a concrete-family target still removes only its own family + // (splitting a familyless rich rule when it matches one). + settings, err := zone.Settings(ctx) + if err != nil { + return err + } + removedRich := false + var reAdd *Rule + for _, richRule := range settings.RichRules { + rule, err := f.UnmarshalRichRule(richRule) + if err != nil { + continue + } + if rule.EqualForRemoval(r, false) { + if err := f.ignoreNotEnabled(zone.RemoveRichRule(ctx, richRule)); err != nil { + return err + } + removedRich = true + // A concrete-family target that matched a genuine dual-family rich rule + // (one stored with no family= attribute) would drop both families; re-add + // the untargeted family below so its coverage survives. + if s := splitDualRow(rule, r); s != nil { + reAdd = s + } + } + } + if removedRich { + if reAdd != nil { + return f.AddRule(ctx, zoneName, reAdd) + } + return nil + } + + // If no stored rule matched, encode the rule and remove it directly; an absent + // rule is treated as already removed. + richRule, err := f.MarshalRichRule(r) + if err != nil { + return err + } + return f.ignoreNotEnabled(zone.RemoveRichRule(ctx, richRule)) +} + +// modeledForwardPort decodes a zone forward-port entry into the NATRule +// GetNATRules reports, or nil for an entry the model cannot hold: an +// unparseable port, a protocol the write side rejects (sctp/dccp — forwardPort +// takes tcp/udp only, so such a rule could never be removed or re-added), or a +// target-port range. An unmodeled entry stays unmanaged, and Restore leaves it +// in place for the same reason. +func (f *FirewallD) modeledForwardPort(fp firewalld.ForwardPort) *NATRule { + pr, perr := ParsePortRange(fp.Port) + if perr != nil { + return nil + } + proto := GetProtocol(fp.Protocol) + if proto != TCP && proto != UDP { + return nil + } + rule := &NATRule{Proto: proto} + if pr.Start == pr.End { + rule.Port = pr.Start + } else { + rule.Ports = []PortRange{pr} + } + if fp.ToPort != "" { + tp, terr := strconv.ParseUint(fp.ToPort, 10, 16) + if terr != nil { + return nil + } + rule.ToPort = uint16(tp) + } + rule.ToAddress = fp.ToAddr + if fp.ToAddr != "" { + rule.Kind = DNAT + } else { + rule.Kind = Redirect + } + return rule +} + +// GetNATRules returns the NAT rules for a zone, mapping forward ports and masquerade. +func (f *FirewallD) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { + zoneName, err = f.resolveZoneName(ctx, zoneName) + if err != nil { + return nil, err + } + + // Read the zone settings for forward ports and masquerade. + settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx) + if err != nil { + return nil, err + } + + // Each forward port maps to a DNAT (when it targets another address) or a + // Redirect (same host, port only). firewalld's model carries no family, so + // these are returned as FamilyAny. An entry the model cannot hold stays + // unmanaged (see modeledForwardPort). + for _, fp := range settings.ForwardPorts { + if rule := f.modeledForwardPort(fp); rule != nil { + rules = append(rules, rule) + } + } + + // Zone masquerade maps to a single Masquerade rule. + if settings.Masquerade { + rules = append(rules, &NATRule{Kind: Masquerade}) + } + + // firewalld isolates NAT by zone; this read is scoped to one zone, so every + // rule lives in zoneName — record the zone and flag it as carrying the prefix. + for _, r := range rules { + r.table = zoneName + r.HasPrefix = true + } + return rules, nil +} + +// forwardPort renders a DNAT/Redirect NAT rule as the arguments firewalld's +// per-zone port-forward API expects (port, protocol, toport, toaddr). That API +// carries only these four fields, so any source, destination or interface match +// cannot be expressed through it and is rejected. A port list has no single +// forward-port form either; AddNATRule/RemoveNATRule fan one into a forward-port +// per spec with expandNATPorts before this row-level renderer runs, so a list +// reaching here means that fan-out was skipped. (firewalld can +// express a source-scoped forward-port in a rich rule, but this backend manages +// NAT through the zone API, which GetNATRules reads back; a rich-rule forward-port +// would not round-trip, so it is intentionally not emitted here.) +func (f *FirewallD) forwardPort(r *NATRule) (firewalld.ForwardPort, error) { + if r.Proto != TCP && r.Proto != UDP { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a tcp or udp protocol: %w", ErrUnsupportedNAT) + } + if !r.HasPorts() { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port: %w", ErrUnsupportedNAT) + } + // A forward-port carries a single port or range; AddNATRule/RemoveNATRule fan a + // port list into one forward-port per spec with expandNATPorts before this + // helper runs, so only a single spec ever reaches here. + specs := r.PortSpecs() + if r.Interface != "" { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface: %w", ErrUnsupportedNAT) + } + if r.Source != "" || r.Destination != "" { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching: %w", ErrUnsupportedNAT) + } + fp := firewalld.ForwardPort{ + Port: specs[0].String(), + Protocol: r.Proto.String(), + // ToAddr is empty for a Redirect (same-host) and set for a DNAT. + ToAddr: r.ToAddress, + } + if r.ToPort != 0 { + fp.ToPort = strconv.FormatUint(uint64(r.ToPort), 10) + } + return fp, nil +} + +// AddNATRule adds a NAT rule to a zone via firewalld's forward-port or masquerade API. +func (f *FirewallD) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := r.validate(); err != nil { + return err + } + + // Get the zone. + zoneName, err := f.resolveZoneName(ctx, zoneName) + if err != nil { + return err + } + zone := f.Conn.Permanent().Zone(zoneName) + + switch r.Kind { + case DNAT, Redirect: + // A forward-port carries a single port or range, never a list, so a + // multi-port match fans into one forward-port add per spec, each + // translating to the same target. + if subs := expandNATPorts(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.AddNATRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + fp, err := f.forwardPort(r) + if err != nil { + return err + } + return f.ignoreAlreadyEnabled(zone.AddForwardPort(ctx, fp)) + case Masquerade: + // This backend manages masquerade through firewalld's per-zone toggle, which + // carries no match, so reject a rule that asks for one. (firewalld can scope a + // masquerade to a source in a rich rule, but the zone toggle is what + // GetNATRules reads back; a rich-rule masquerade would not round-trip.) + if r.Interface != "" || r.Source != "" || r.Destination != "" || r.Proto != ProtocolAny || r.HasPorts() { + return fmt.Errorf("this backend's zone masquerade cannot match on interface, address, protocol or port: %w", ErrUnsupportedNAT) + } + return f.ignoreAlreadyEnabled(zone.AddMasquerade(ctx)) + case SNAT: + return fmt.Errorf("firewalld does not support snat in this model: %w", ErrUnsupportedNAT) + } + return fmt.Errorf("invalid nat kind") +} + +// InsertNATRule is unsupported: firewalld models NAT through zone toggles and rich +// rules, which carry no explicit ordering. +func (f *FirewallD) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + return unsupportedOrdering(f.Type()) +} + +// MoveNATRule is unsupported for the same reason as InsertNATRule. +func (f *FirewallD) MoveNATRule(ctx context.Context, zoneName string, r *NATRule, position int) error { + return unsupportedOrdering(f.Type()) +} + +// RemoveNATRule removes a NAT rule from a zone via firewalld's forward-port or masquerade API. +func (f *FirewallD) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := r.validate(); err != nil { + return err + } + + // Get the zone. + zoneName, err := f.resolveZoneName(ctx, zoneName) + if err != nil { + return err + } + zone := f.Conn.Permanent().Zone(zoneName) + + switch r.Kind { + case DNAT, Redirect: + // The inverse of AddNATRule's fan-out: a multi-port match is stored as + // one forward-port per spec, so its removal fans into one remove per spec. + if subs := expandNATPorts(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.RemoveNATRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + fp, err := f.forwardPort(r) + if err != nil { + return err + } + return f.ignoreNotEnabled(zone.RemoveForwardPort(ctx, fp)) + case Masquerade: + return f.ignoreNotEnabled(zone.RemoveMasquerade(ctx)) + case SNAT: + return fmt.Errorf("firewalld does not support snat in this model: %w", ErrUnsupportedNAT) + } + return fmt.Errorf("invalid nat kind") +} + +// policyFromTarget maps a firewalld zone target to a default action. The +// "default"/"%%REJECT%%"/empty targets behave as a reject, the only ones a zone +// accepts explicitly being ACCEPT and DROP. +func (f *FirewallD) policyFromTarget(target string) Action { + switch strings.ToUpper(target) { + case "ACCEPT": + return Accept + case "DROP": + return Drop + case "", "DEFAULT", "%%REJECT%%", "REJECT": + return Reject + } + return Reject +} + +// GetDefaultPolicy returns the zone's default input policy, derived from its target. +func (f *FirewallD) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + zoneName, err := f.resolveZoneName(ctx, zoneName) + if err != nil { + return nil, err + } + settings, err := f.Conn.Permanent().Zone(zoneName).Settings(ctx) + if err != nil { + return nil, err + } + // firewalld zones only model the input side; a packet that matches no rule + // is handled by the zone target. + return &DefaultPolicy{Input: f.policyFromTarget(string(settings.Target))}, nil +} + +// SetDefaultPolicy sets the zone's default input policy via its target; firewalld +// zones only expose the input direction. +func (f *FirewallD) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + if policy == nil { + return fmt.Errorf("policy cannot be nil") + } + // firewalld zones only expose the input direction. + if policy.Output != ActionInvalid || policy.Forward != ActionInvalid { + return fmt.Errorf("firewalld only manages the input default policy: %w", ErrUnsupportedPolicy) + } + if policy.Input == ActionInvalid { + return nil + } + var target firewalld.Target + switch policy.Input { + case Accept: + target = firewalld.TargetACCEPT + case Drop: + target = firewalld.TargetDROP + case Reject: + target = firewalld.TargetReject + default: + return fmt.Errorf("invalid default policy action") + } + zoneName, err := f.resolveZoneName(ctx, zoneName) + if err != nil { + return err + } + return f.Conn.Permanent().Zone(zoneName).SetTarget(ctx, target) +} + +// --- address sets (firewalld ipsets) ---------------------------------------- + +// getAddressSet reads a single firewalld ipset, or nil if it does not exist or +// has a type the model cannot hold. Only hash:ip and hash:net map onto SetType; +// coercing another type (hash:mac, hash:ip,port, ...) would let a Backup/Restore +// rewrite the set as a bare hash:ip and break it, so those stay unmanaged. +func (f *FirewallD) getAddressSet(ctx context.Context, name string) (*AddressSet, error) { + settings, err := f.Conn.Permanent().IPSet(name).Settings(ctx) + if errors.Is(err, firewalld.ErrInvalidIPSet) { + return nil, nil + } + if err != nil { + return nil, err + } + set := &AddressSet{Name: name, Entries: settings.Entries} + switch settings.Type { + case "hash:ip": + set.Type = SetHashIP + case "hash:net": + set.Type = SetHashNet + default: + return nil, nil + } + switch settings.Options["family"] { + case "inet6": + set.Family = IPv6 + case "inet", "": + set.Family = IPv4 + } + return set, nil +} + +// GetAddressSets returns all permanent firewalld ipsets as address sets. +func (f *FirewallD) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + names, err := f.Conn.Permanent().IPSetNames(ctx) + if err != nil { + return nil, err + } + result := make([]*AddressSet, 0, len(names)) + for _, name := range names { + set, err := f.getAddressSet(ctx, name) + if err != nil { + return nil, err + } + if set == nil { + continue + } + result = append(result, set) + } + return result, nil +} + +// GetAddressSet returns the named permanent ipset, or an error if it does not exist. +func (f *FirewallD) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + set, err := f.getAddressSet(ctx, name) + if err != nil { + return nil, err + } + if set == nil { + return nil, fmt.Errorf("address set %q not found", name) + } + return set, nil +} + +// ipSetType maps an AddressSet type to a firewalld ipset type string. +func (f *FirewallD) ipSetType(t SetType) string { + if t == SetHashNet { + return "hash:net" + } + return "hash:ip" +} + +// AddAddressSet creates the permanent ipset, or updates it in place when it already exists. +func (f *FirewallD) AddAddressSet(ctx context.Context, set *AddressSet) error { + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + settings := firewalld.IPSetSettings{ + Name: set.Name, + Type: f.ipSetType(set.Type), + Entries: set.Entries, + Options: map[string]string{"family": "inet"}, + } + if set.Family == IPv6 { + settings.Options["family"] = "inet6" + } + + // If the set already exists, update it in place; otherwise create it. + names, err := f.Conn.Permanent().IPSetNames(ctx) + if err != nil { + return err + } + for _, n := range names { + if n == set.Name { + return f.Conn.Permanent().IPSet(set.Name).Update(ctx, settings) + } + } + if _, err := f.Conn.Permanent().AddIPSet(ctx, set.Name, settings); err != nil { + return err + } + return nil +} + +// RemoveAddressSet removes the named permanent ipset. Remove resolves the ipset +// by name itself and returns firewalld.ErrInvalidIPSet if it does not exist, so a +// separate existence pre-check would just double the D-Bus round trips. +func (f *FirewallD) RemoveAddressSet(ctx context.Context, name string) error { + err := f.Conn.Permanent().IPSet(name).Remove(ctx) + // A missing set is a no-op; any other error (D-Bus disconnect, permission + // denial, ...) must surface. + if errors.Is(err, firewalld.ErrInvalidIPSet) { + return nil + } + return err +} + +// AddAddressSetEntry adds entry to the named permanent ipset. AddEntry resolves +// the ipset by name itself and returns firewalld.ErrInvalidIPSet if it does not +// exist, so a separate existence pre-check would just double the D-Bus round trips. +func (f *FirewallD) AddAddressSetEntry(ctx context.Context, name, entry string) error { + err := f.Conn.Permanent().IPSet(name).AddEntry(ctx, entry) + if errors.Is(err, firewalld.ErrInvalidIPSet) { + return fmt.Errorf("address set %q not found", name) + } + return err +} + +// RemoveAddressSetEntry removes entry from the named permanent ipset. RemoveEntry +// resolves the ipset by name itself and returns firewalld.ErrInvalidIPSet if it +// does not exist, so a separate existence pre-check would just double the D-Bus +// round trips. +func (f *FirewallD) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + err := f.Conn.Permanent().IPSet(name).RemoveEntry(ctx, entry) + if errors.Is(err, firewalld.ErrInvalidIPSet) { + return fmt.Errorf("address set %q not found", name) + } + return err +} + +// Backup captures the current filter and NAT rules managed by this backend. +func (f *FirewallD) Backup(ctx context.Context, zoneName string) (*Backup, error) { + rules, err := f.GetRules(ctx, zoneName) + if err != nil { + return nil, err + } + natRules, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return nil, err + } + // GetRules/GetNATRules are already scoped to this zone, so the backup captures + // exactly the zone's rules; captureBackupState adds the zone's default policy + // (its target) and the managed ipsets. + backup := &Backup{Rules: rules, NATRules: natRules} + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *FirewallD) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + + // Get the zone. + zoneName, err := f.resolveZoneName(ctx, zoneName) + if err != nil { + return err + } + zone := f.Conn.Permanent().Zone(zoneName) + + settings, err := zone.Settings(ctx) + if err != nil { + return err + } + + // Remove the zone entries and rich rules the model manages. Each removal + // mirrors the guard its read side applies, so an entry GetRules skips as + // unmodeled (a dccp port, an unrecognized protocol, an unparseable rich rule) + // is preserved rather than destroyed by a Backup that never captured it. + for _, port := range settings.Ports { + if len(f.zonePortRules([]firewalld.Port{port}, false)) == 0 { + continue + } + if err := zone.RemovePort(ctx, port); err != nil { + return err + } + } + for _, sp := range settings.SourcePorts { + if len(f.zonePortRules([]firewalld.Port{sp}, true)) == 0 { + continue + } + if err := zone.RemoveSourcePort(ctx, sp); err != nil { + return err + } + } + for _, source := range settings.Sources { + if err := zone.RemoveSource(ctx, source); err != nil { + return err + } + } + for _, proto := range settings.Protocols { + if GetProtocol(proto) == ProtocolAny { + continue + } + if err := zone.RemoveProtocol(ctx, proto); err != nil { + return err + } + } + for _, richRule := range settings.RichRules { + if _, perr := f.UnmarshalRichRule(richRule); perr != nil { + continue + } + if err := zone.RemoveRichRule(ctx, richRule); err != nil { + return err + } + } + + // Remove the modeled NAT entries, preserving forward ports the model cannot + // hold for the same reason. + for _, fp := range settings.ForwardPorts { + if f.modeledForwardPort(fp) == nil { + continue + } + if err := zone.RemoveForwardPort(ctx, fp); err != nil { + return err + } + } + if settings.Masquerade { + if err := zone.RemoveMasquerade(ctx); err != nil { + return err + } + } + + // Recreate the ipsets before the rules that reference them (the managed rich + // rules were removed above, so nothing holds a set reference). + if err := restoreBackupSets(ctx, f, backup, false); err != nil { + return err + } + + // Re-add rules from backup. + for _, r := range backup.Rules { + if err := f.AddRule(ctx, zoneName, r); err != nil { + return err + } + } + for _, r := range backup.NATRules { + if err := f.AddNATRule(ctx, zoneName, r); err != nil { + return err + } + } + // Re-assert the zone's captured default policy (its target). + return applyBackupPolicy(ctx, f, zoneName, backup) +} + +// Reload reloads firewalld's permanent configuration into the runtime. +func (f *FirewallD) Reload(ctx context.Context) error { + return f.Conn.Reload(ctx) +} + +// Close releases the D-Bus connection to firewalld. +func (f *FirewallD) Close(ctx context.Context) error { + return f.Conn.Close() +} diff --git a/firewalld_linux_test.go b/firewalld_linux_test.go new file mode 100644 index 0000000..cf65c0c --- /dev/null +++ b/firewalld_linux_test.go @@ -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"})) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..96b2ea4 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b274d10 --- /dev/null +++ b/go.sum @@ -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= diff --git a/hooks_linux.go b/hooks_linux.go new file mode 100644 index 0000000..e35f23a --- /dev/null +++ b/hooks_linux.go @@ -0,0 +1,1175 @@ +package firewall + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/anmitsu/go-shlex" +) + +// hookScript injects raw iptables/ip6tables commands into a CSF or APF hook that +// the firewall sources at (re)load time. It lets those backends express filter +// rules their native config cannot — connection-state, per-rule interface, +// logging, rate limiting, ICMPv6, and the transport protocols SCTP, GRE, ESP and +// AH — by reusing the iptables rule marshaller/parser and writing the resulting +// commands directly into the firewall's documented hook. It also parses the +// iptables/ip6tables commands in the hook back into rules. +// +// A rule's comment is carried as a full-line `#` script comment directly above +// its command line(s), the same convention the csf.allow/apf trust files use, so +// the command lines themselves stay free of shell-quoted comment text. An +// iptables `-m comment` embedded in a line — hand-added, or written by an older +// library version — takes precedence on read, and the prefix tag from either +// source marks the rule as HasPrefix (see resolveComment). +type hookScript struct { + // rulePrefix tags each injected rule through the script comment written + // above its lines so it can be told apart from other rules. + rulePrefix string + // hookPath is the firewall hook this library writes its command lines into. It + // runs before the firewall adds its own rules, so injected rules sit at the top + // of the INPUT/OUTPUT chains. + hookPath string + // hookPerm is the mode a newly created hook file is given (0700 for CSF, 0750 + // for APF); an existing hook keeps its own mode, except for the execute bit + // (see commit). + hookPerm os.FileMode + // ipv6Enabled mirrors the backend's own IPv6 handling (csf.conf IPV6, conf.apf + // USE_IPV6). With it off, a family-agnostic rule is written for IPv4 only (see + // ruleLines). + ipv6Enabled bool + // ip4Cmd, ip6Cmd and ipsetCmd are the commands the written lines invoke. The + // backends fill them with absolute paths (newHookScript) so the hook does not + // depend on the PATH it is sourced with; an empty field falls back to the bare + // command name and leaves resolution to the shell. + ip4Cmd string + ip6Cmd string + ipsetCmd string +} + +// newHookScript binds a hookScript to a firewall's hook file, resolving the +// commands its lines invoke to absolute paths. +func newHookScript(rulePrefix, hookPath string, hookPerm os.FileMode, ipv6Enabled bool) *hookScript { + return &hookScript{ + rulePrefix: rulePrefix, + hookPath: hookPath, + hookPerm: hookPerm, + ipv6Enabled: ipv6Enabled, + ip4Cmd: resolveHookBinary("iptables"), + ip6Cmd: resolveHookBinary("ip6tables"), + ipsetCmd: resolveHookBinary("ipset"), + } +} + +// commit installs a staged hook file and makes sure it is left executable. +// +// The execute bit is the hook's activation switch, not a mode the operator +// chose: apf runs hook_pre.sh only when it is executable and ships it 0640, and +// csf's csfpre.sh works the same way. An atomic write preserves an existing +// file's mode, so without this the library's lines land in a file the firewall +// never runs — they read back from the file correctly while never reaching the +// kernel. Only the owner-execute bit is forced; the rest of the mode and the +// ownership stay as they were. +func (h *hookScript) commit(af *atomicFile) error { + if err := af.Commit(); err != nil { + return err + } + fi, err := os.Stat(h.hookPath) + if err != nil { + return err + } + if fi.Mode().Perm()&0100 != 0 { + return nil + } + return os.Chmod(h.hookPath, fi.Mode().Perm()|0100) +} + +// resolveHookBinary returns the absolute path of a command the hook invokes, so +// an injected line does not depend on the PATH the firewall happens to source the +// hook with. csf.pl and apf both prepend the standard sbin directories before +// sourcing, but neither guarantees them across versions, and a hook run by hand +// or from a service unit inherits whatever environment its caller had — an +// unresolved `iptables` there fails the line silently at load time. Falls back to +// the bare name when the tool cannot be found, leaving resolution to the shell. +func resolveHookBinary(name string) string { + bin, _ := resolveBinary(name) + return bin +} + +// --- hook routing (rule shapes the native configs cannot express) ------------ + +// hookOnlyProto reports whether a protocol has no representation in CSF's or +// APF's native config and so can only be applied through the raw-iptables hook. +func hookOnlyProto(p Protocol) bool { + switch p { + case SCTP, GRE, ESP, AH: + return true + } + return false +} + +// ruleNeedsHook reports whether a rule requires a feature that CSF/APF cannot +// express in their native config and so must be injected as a raw iptables rule +// through the hook: a forward-chain (routed) rule, connection-state matching, +// per-rule interface matching, logging, rate limiting, ICMPv6, a transport +// protocol their native config does not model (SCTP and the portless IP protocols +// GRE, ESP and AH), an address-set reference (@set), or a negated address. +// csf.allow/apf trust files take literal addresses only, so a `-m set +// --match-set` match lives in the hook beside the ipset commands that create the +// set, and a negated address lives there as iptables' native `! -s`/`! -d` — +// csf.pl passes an advanced line's s=/d= value verbatim to iptables (where a +// joined "!" is not a reliable negation) and skips a plain "!"-prefixed line as +// not an address, so neither native form can carry one. +func ruleNeedsHook(r *Rule) bool { + return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" || + r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto) || + isSetRef(r.Source) || isSetRef(r.Destination) || + strings.HasPrefix(r.Source, "!") || strings.HasPrefix(r.Destination, "!") +} + +// bareHostShape reports whether a rule has the shape a plain csf.allow/apf +// allow_hosts line expresses: exactly one source or destination address, no ports, +// and the any-protocol match. Its direction is not considered — a DirAny bare host +// is the single bidirectional plain line, while a concrete-direction one is one-way +// (see bareHostOneWay). Shared by CSF and APF. +func bareHostShape(r *Rule) bool { + if r.HasPorts() || r.HasSourcePorts() || r.Proto != ProtocolAny { + return false + } + // A set reference (@set) is not a literal host: it matches through the hook's + // `-m set` clause (ruleNeedsHook routes it there), never a plain trust-file line. + if isSetRef(r.Source) || isSetRef(r.Destination) { + return false + } + return (r.Source != "") != (r.Destination != "") +} + +// bareHostOneWay reports whether a rule is a ONE-WAY bare-address host allow/deny: +// the bare host shape with a concrete input or output direction. A plain line matches +// a host in BOTH directions, and neither backend's advanced-rule format can carry an +// address without a port, so a one-way bare host rule is expressed through the +// raw-iptables hook instead. +func bareHostOneWay(r *Rule) bool { + return bareHostShape(r) && (r.Direction == DirInput || r.Direction == DirOutput) +} + +// dirAnyPlainLine reports whether a DirAny rule maps to a single bidirectional plain +// csf.allow/apf line: a bare host carrying no feature that would force the +// raw-iptables hook (connection state, interface, logging, etc.). Every other DirAny +// rule fans out into a concrete input rule plus its role-swapped output rule on +// add/remove, since csf/apf have no single native both-directions construct for it. +func dirAnyPlainLine(r *Rule) bool { + return r.Direction == DirAny && bareHostShape(r) && !ruleNeedsHook(r) +} + +// shapeNeedsHook reports whether a rule's shape overflows every native CSF/APF +// config form and must be injected through the raw-iptables hook. The native forms +// are narrow — a plain trust-file line holds one address, matches both directions, +// and is all-protocol; an advanced line holds exactly one address field and one +// port-flow field and requires both; the conf lists key on a port or an icmp type — +// while iptables expresses every overflow directly (`-s` with `-d`, `--sport` with +// `--dport`, a bare `-p tcp -j ACCEPT`), so these shapes are hooked rather than +// rejected: the alternative is failing on a rule the firewall can enforce. +// +// Feature-based routing (state, interface, logging, rate limiting, ICMPv6, +// hook-only transports, set references, negation) is ruleNeedsHook's job, and the +// backend-specific connlimit, ICMP and port-list shapes stay in each backend's +// needsHook; ICMP is excluded from every test here because both backends route it +// there (CSF's typed advanced rule, APF.isConfRule). Shared by CSF and APF. +func shapeNeedsHook(r *Rule) bool { + // A one-way bare host: a plain line is bidirectional and an advanced rule + // requires a port, so a concrete-direction bare host has no native form. + if bareHostOneWay(r) { + return true + } + // Portless address shapes no trust file expresses. + if !r.Proto.IsICMP() && !r.HasPorts() && !r.HasSourcePorts() { + // A source+destination pair has no single-address advanced/plain form. + if r.Source != "" && r.Destination != "" { + return true + } + // A single-address host pinned to a transport has no portless form: the plain + // line is all-protocol and the advanced rule requires a port. TCPUDP counts — + // it names transports, so it is not the all-protocol plain line either. + if (r.Source != "" || r.Destination != "") && onProtocolAxis(r.Proto) { + return true + } + } + // Advanced-line overflows, on the protocols an iptables port or icmp match + // accepts: a port on ProtocolAny is inexpressible in iptables too, so it stays + // on the native path and is rejected there by Rule.validate rather than + // reaching the hook and failing there. + if onProtocolAxis(r.Proto) || r.Proto.IsICMP() { + // One port-flow field: a source port and a destination port cannot share it. + if r.HasPorts() && r.HasSourcePorts() { + return true + } + // An advanced rule requires an address, so a bare source-port match has no + // advanced form at all; iptables matches --sport on its own. + if r.HasSourcePorts() && r.Source == "" && r.Destination == "" { + return true + } + // One address field: a ported or icmp-matching source+destination pair cannot + // share it (its portless non-ICMP counterpart is routed above). + if r.Source != "" && r.Destination != "" && + (r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()) { + return true + } + } + // A bare protocol match — a non-ICMP transport with no address and no port — has + // no native construct (the trust files key on an address, the conf lists on a + // port or icmp type) but iptables applies it directly. + return r.Source == "" && r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && !r.Proto.IsICMP() +} + +// --- hook file primitives ---------------------------------------------------- + +// command returns the iptables command a written line invokes for a family: the +// resolved absolute path when the hook has one, otherwise the bare name. +func (h *hookScript) command(fam Family) string { + if fam == IPv6 { + if h.ip6Cmd != "" { + return h.ip6Cmd + } + return "ip6tables" + } + if h.ip4Cmd != "" { + return h.ip4Cmd + } + return "iptables" +} + +// ipsetCommand returns the ipset command an address-set line invokes, resolved as +// with command. +func (h *hookScript) ipsetCommand() string { + if h.ipsetCmd != "" { + return h.ipsetCmd + } + return "ipset" +} + +// hookCmdIs reports whether a hook line's command token invokes name. Only the +// command's base name is compared, so every spelling of the same tool matches: +// the bare name a hand-written line uses, the resolved path this library writes, +// and any other path an operator wrote by hand (`/sbin/ipset` read by a manager +// that resolved `/usr/sbin/ipset`). Quotes are stripped, since the hook is shell. +func hookCmdIs(tok, name string) bool { + tok = trimQuotes(tok) + if tok == "" { + return false + } + return filepath.Base(tok) == name +} + +// hookRuleCommands maps the base name of a rule command to the family it selects. +// Alongside the plain names it covers the update-alternatives variants a +// hand-written line may invoke directly (Debian's iptables-nft/-legacy). The +// save/restore front-ends are deliberately absent: they are not rule commands. +var hookRuleCommands = map[string]Family{ + "iptables": IPv4, + "iptables-legacy": IPv4, + "iptables-nft": IPv4, + "ip6tables": IPv6, + "ip6tables-legacy": IPv6, + "ip6tables-nft": IPv6, +} + +// hookCmdFamily splits a hook command line into the family its command selects +// and the arguments that follow, reporting whether the line invokes a rule +// command at all. The command is matched on its base name (see hookCmdIs), so a +// line spelled with any path — or none — reads back the same. A command with no +// arguments is not a rule line. +func hookCmdFamily(line string) (fam Family, rest string, ok bool) { + cmd, rest, _ := strings.Cut(strings.TrimSpace(line), " ") + rest = strings.TrimSpace(rest) + cmd = trimQuotes(cmd) + if rest == "" || cmd == "" { + return FamilyAny, "", false + } + if fam, ok := hookRuleCommands[filepath.Base(cmd)]; ok { + return fam, rest, true + } + return FamilyAny, "", false +} + +// resolveComment derives a parsed hook rule's user-facing comment and prefix +// flag from the iptables comment embedded in its line and the script comment +// above it. An embedded comment's text wins — foreign lines and lines written +// by an older library version carry one — while the script comment is the form +// this library writes; the prefix tag counts from either source. +func (h *hookScript) resolveComment(embedded, script string) (text string, hasPrefix bool) { + et, eh := prefixedComment(h.rulePrefix, embedded) + st, sh := prefixedComment(h.rulePrefix, script) + if embedded != "" { + return et, eh || sh + } + return st, sh +} + +// hookGroup is one physical span of the hook: an iptables/ip6tables command +// line together with the script comment lines attached above it (a LOG line and +// its adjacent action line count as one logical group), or any other line on +// its own. raw preserves the original lines so a rewrite copies user formatting +// through verbatim; rule/nat hold the parsed logical rule when the group +// encodes one, and cmds marks a group whose line is a command even when neither +// parser models it. +type hookGroup struct { + raw []string + cmds []string + rule *Rule + nat *NATRule +} + +// scanGroups streams the hook's groups to fn in file order, built on the shared +// comment-group scanner (scanCommentGroups): each full-line script comment +// attaches to the command line below it, and a LOG line pairs with the action +// line under it into the one logged rule they encode. An error from fn stops +// the scan. +func (h *hookScript) scanGroups(fd *os.File, fn func(g hookGroup) error) error { + // held is a parsed LOG-only group waiting to see whether the next command + // line is its action partner. iptables writes a logged rule as two lines (a + // non-terminal LOG line then the action line), so we buffer the LOG line here + // rather than emit it, resolving its comment only once its fate is decided. + type heldGroup struct { + g hookGroup + rule *Rule + embedded string + script string + } + var held *heldGroup + // emitHeld flushes any buffered LOG group, whether it merged with a partner + // or stayed an orphan LOG-only rule. resolveComment folds the embedded + // comment (from a paired action line) over the script comment. + emitHeld := func() error { + if held == nil { + return nil + } + hg := held + held = nil + hg.rule.Comment, hg.rule.HasPrefix = h.resolveComment(hg.embedded, hg.script) + hg.g.rule = hg.rule + return fn(hg.g) + } + isCommand := func(trimmed string) bool { + _, _, ok := hookCmdFamily(trimmed) + return ok + } + err := scanCommentGroups(fd, h.rulePrefix, isCommand, func(cg commentGroup) error { + // A passthrough line sits between a held LOG line and any later action + // line, so it ends the pairing. + if cg.line == "" { + if err := emitHeld(); err != nil { + return err + } + return fn(hookGroup{raw: cg.raw}) + } + g := hookGroup{raw: cg.raw, cmds: []string{cg.line}} + if nat, ok := h.parseNATLine(cg.line); ok { + if err := emitHeld(); err != nil { + return err + } + _, sp := prefixedComment(h.rulePrefix, cg.comment) + nat.HasPrefix = nat.HasPrefix || sp + g.nat = nat + return fn(g) + } + rule, ok := h.parseLine(cg.line) + if !ok { + // A command line neither parser models still counts as a command + // (cmds set), so set removal refuses to strand a referencing rule. + if err := emitHeld(); err != nil { + return err + } + return fn(g) + } + // Pair a held LOG line with the action line directly under it. The + // len(cg.raw) == 1 guard enforces physical adjacency: a raw slice longer + // than one line means a comment attached to this action, so the LOG and + // action are not consecutive and must stay separate — pairing them would + // synthesize a rule no removal could locate. On a match, fold the two + // lines into the one logged rule they encode and emit it. + if held != nil && len(cg.raw) == 1 && logPartner(held.rule, rule) { + if rule.Comment != "" { + held.embedded = rule.Comment + } + held.rule = mergeLogPair(held.rule, rule) + held.g.raw = append(held.g.raw, cg.raw[0]) + held.g.cmds = append(held.g.cmds, cg.line) + return emitHeld() + } + // This line is not a partner, so any held LOG line is now an orphan; + // flush it before handling this line. + if err := emitHeld(); err != nil { + return err + } + // Buffer a bare LOG line (Log set, no terminal action) to pair against the + // next command line; every other rule is complete on its own and emitted + // at once. + if rule.Action == ActionInvalid && rule.Log { + held = &heldGroup{g: g, rule: rule, embedded: rule.Comment, script: cg.comment} + return nil + } + rule.Comment, rule.HasPrefix = h.resolveComment(rule.Comment, cg.comment) + g.rule = rule + return fn(g) + }) + if err != nil { + return err + } + return emitHeld() +} + +// shellSafeToken quotes a token so /bin/sh passes it through verbatim. The +// iptables marshaller quotes free-text fields (a log prefix) for an +// iptables-restore file, where double quotes are literal — but the hook is +// sourced by /bin/sh, which expands $var, $(...) and backticks inside double +// quotes. A token made of ordinary argument characters is returned bare for +// readability; anything else is wrapped in single quotes (with any embedded +// single quote escaped), which the shell treats as a literal. shlex.Split +// reverses either form on read-back. +func shellSafeToken(tok string) string { + safe := tok != "" + for _, r := range tok { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || + strings.ContainsRune("_./:=,+-@%", r) { + continue + } + safe = false + break + } + if safe { + return tok + } + return "'" + strings.ReplaceAll(tok, "'", `'\''`) + "'" +} + +// --- filter rules (raw iptables commands in the hook) ------------------------ + +// linesForRows encodes family-concrete rule rows (an expandFamilies fan-out) as +// the raw command line(s) to inject: one iptables (or ip6tables) command per +// underlying iptables line and per row. A logged rule yields a LOG line followed +// by its action line, as with the iptables backend. The comment is not +// marshalled into the line — it rides as the script comment edit writes above +// the lines instead. Each marshalled line is re-tokenized and re-quoted +// shell-safely, because the hook script is sourced by /bin/sh rather than exec'd +// argv-style. +func (h *hookScript) linesForRows(rows []*Rule) ([]string, error) { + var out []string + for _, row := range rows { + fam := row.impliedFamily() + cmd := h.command(fam) + // iptables has no both-transports match, so a TCPUDP rule fans out into a tcp + // line and a udp line; a portless ProtocolAny rule is a valid protocol-agnostic + // match (a bare `-j ACCEPT`) and is not fanned. + for _, sub := range expandProtocols(row) { + rc := *sub + rc.Family = fam + rc.Comment = "" + ipt := &IPTables{} + // The hook borrows the iptables encoder, so it also runs its checks: + // the rule reached here through a routing backend's entry point, not + // iptables', and expandProtocols above supplied the concrete cell. + if err := ipt.validateRule(&rc); err != nil { + return nil, err + } + base, err := ipt.marshalRuleLines(&rc) + if err != nil { + return nil, err + } + for _, line := range base { + tokens, terr := shlex.Split(line, true) + if terr != nil { + return nil, terr + } + for i, t := range tokens { + tokens[i] = shellSafeToken(t) + } + out = append(out, cmd+" "+strings.Join(tokens, " ")) + } + } + } + return out, nil +} + +// ruleLines encodes a rule as the raw command line(s) an edit acts on. An add +// writes only the family rows the backend enforces (filterFamiliesIPv6): the +// hook runs on every (re)load regardless, but neither csf nor apf flushes +// ip6tables — filter or nat table — while IPv6 is disabled (csf.pl guards the +// v6 flush behind IPV6; apf's ipt6() is a no-op unless USE_IPV6=1), so an +// injected ip6tables line would be re-appended on each reload and would outlive +// its own removal from the hook. A family-agnostic rule that references an +// address set is pinned to the set's family instead: an ipset is single-family, +// so the opposite-family line would fail every time the firewall sources the +// hook. A removal sweeps every family row the rule could occupy +// (expandFamilies) regardless of the IPv6 setting, so an ip6tables line written +// while IPv6 was enabled — or added by hand — is still cleared once it is +// switched off, rather than stranded in the hook. +func (h *hookScript) ruleLines(r *Rule, remove bool) ([]string, error) { + if remove { + return h.linesForRows(expandFamilies(r)) + } + if r.impliedFamily() == FamilyAny && (isSetRef(r.Source) || isSetRef(r.Destination)) { + // Resolved live-first, then from the hook's own ipset lines for a set + // just written but not loaded by the firewall yet. + fam, err := ipsetRefFamily(r.Source, r.Destination, h.getAddressSets) + if err != nil { + return nil, err + } + if fam == IPv6 && !h.ipv6Enabled { + return nil, fmt.Errorf("rule references an IPv6 address set while IPv6 is disabled: %w", ErrUnsupported) + } + rc := *r + rc.Family = fam + return h.linesForRows([]*Rule{&rc}) + } + return h.linesForRows(filterFamiliesIPv6(h.ipv6Enabled, r)) +} + +// parseLine decodes an injected command line back into the rule it represents +// (one line, so a LOG line yields a rule with Log set and no action), reporting +// whether the line is one this backend recognizes. Any embedded iptables comment +// is left as its raw text; scanGroups resolves it against the script +// comment above the line (see resolveComment). +func (h *hookScript) parseLine(line string) (*Rule, bool) { + fam, rest, ok := hookCmdFamily(line) + if !ok { + return nil, false + } + r, err := unmarshalIPTablesRule(rest, fam) + if err != nil { + return nil, false + } + return r, true +} + +// getRules parses the hook into logical rules, each LOG line coalesced with the +// action line that follows it and each rule carrying the comment resolved from +// its script or embedded comment. Every command line is read, including any a +// user authored by hand, so the library reconciles the hook's real state. Family +// merging is left to the caller, which unions these with the backend's native +// rules. +func (h *hookScript) getRules() ([]*Rule, error) { + fd, err := os.Open(h.hookPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = fd.Close() }() + var rules []*Rule + err = h.scanGroups(fd, func(g hookGroup) error { + // An orphan LOG line (its action partner hand-edited away) is not a + // reportable rule; removal still sweeps it (see Rule.OrphanLogMatchesAny). + if g.rule != nil && g.rule.Action != ActionInvalid { + rules = append(rules, g.rule) + } + return nil + }) + return rules, err +} + +// hookUnit is one logical rule as its hook line(s) — a LOG pair is two lines, +// every other rule one — with the rule those lines encode. +type hookUnit struct { + lines []string + rule *Rule +} + +// lineUnits parses marshalled command lines back into the logical units they +// encode, pairing each LOG line with its action line. The round trip normalizes +// field spellings, which edit's matching depends on, so a line that fails to +// parse is an error rather than a silent no-op. +func (h *hookScript) lineUnits(lines []string) ([]hookUnit, error) { + var units []hookUnit + for i := 0; i < len(lines); i++ { + r, ok := h.parseLine(lines[i]) + if !ok { + return nil, fmt.Errorf("hook line does not round-trip: %q", lines[i]) + } + if i+1 < len(lines) { + if p, pok := h.parseLine(lines[i+1]); pok && logPartner(r, p) { + units = append(units, hookUnit{lines: []string{lines[i], lines[i+1]}, rule: mergeLogPair(r, p)}) + i++ + continue + } + } + units = append(units, hookUnit{lines: []string{lines[i]}, rule: r}) + } + return units, nil +} + +// edit adds or removes a rule's command line(s) directly in the hook, rewriting +// the file in a single streamed pass. Both directions match on the underlying +// rule, never the comment, which is not part of rule identity: an add +// is satisfied by an existing line meaning the same rule even when it is spelled +// or commented differently, and a removal drops a copy of the rule a customer +// added under a different comment (or none) too. An added unit is appended with +// its script comment written above it; a dropped one takes its attached comment +// lines with it. A LOG line and the action line under it are matched as the one +// logged rule they encode, never independently: a logged rule and its unlogged +// twin are distinct rules, and removing one must not strip the other's lines. +// Every other hook line — user-authored shell and rules alike — streams through +// to the staged rewrite untouched; it reports whether the hook changed. Adding +// to an absent hook creates it; removing from one is a no-op. +func (h *hookScript) edit(r *Rule, remove bool) (bool, error) { + desired, err := h.ruleLines(r, remove) + if err != nil { + return false, err + } + units, err := h.lineUnits(desired) + if err != nil { + return false, err + } + + // A missing hook scans as empty: an add creates it, a removal is a no-op. + fd, err := os.Open(h.hookPath) + if err != nil { + if !os.IsNotExist(err) { + return false, err + } + if remove { + return false, nil + } + } else { + defer func() { _ = fd.Close() }() + } + + af, err := newAtomicFile(h.hookPath, h.hookPerm) + if err != nil { + return false, err + } + defer af.Abort() + // A freshly created hook gets a shebang; the firewall sources it as shell. + if fd == nil { + _, _ = fmt.Fprintln(af, "#!/bin/sh") + } + + if remove { + targets := make([]*Rule, 0, len(units)) + for _, u := range units { + targets = append(targets, u.rule) + } + changed := false + err = h.scanGroups(fd, func(g hookGroup) error { + // An orphan LOG line whose action partner was hand-edited away still + // belongs to the logged rule named by the removal. + if g.rule != nil && (g.rule.MatchesAny(targets) || g.rule.OrphanLogMatchesAny(targets)) { + changed = true + return nil + } + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + return nil + }) + if err != nil { + return false, err + } + if !changed { + return false, nil + } + return true, h.commit(af) + } + + // Copy the hook through, noting which wanted units it already holds. + present := make([]bool, len(units)) + err = h.scanGroups(fd, func(g hookGroup) error { + if g.rule != nil { + for i, u := range units { + if !present[i] && g.rule.Equal(u.rule, true) { + present[i] = true + } + } + } + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + return nil + }) + if err != nil { + return false, err + } + + // Append the units the hook does not already hold, each under its script + // comment. A rule that fans out is completed unit by unit, so a subset left + // by a prior single-family add or a manual edit is filled in rather than + // duplicated on every reconcile. + changed := false + comment := combineComment(h.rulePrefix, r.Comment) + for i, u := range units { + if present[i] { + continue + } + if comment != "" { + _, _ = fmt.Fprintln(af, "# "+comment) + } + for _, l := range u.lines { + _, _ = fmt.Fprintln(af, l) + } + changed = true + } + if !changed { + return false, nil + } + return true, h.commit(af) +} + +// --- address sets (ipset commands in the hook) ----------------------------- +// +// CSF and APF have no native address-set construct, so the library persists a +// set as `ipset` commands in the same hook that carries its raw iptables rules. +// The firewall sources the hook on every (re)start, so the ipset commands +// recreate the set before the `-m set --match-set` rule lines that follow can +// reference it — the set survives a reboot exactly as the hook's rules do. Every +// ipset line is kept ahead of every iptables/ip6tables line to preserve that +// ordering. Reading foreign, user-authored ipset lines is intended, as with +// rules: the library manages the actual hook state. + +// ipsetLinesFor renders the hook lines that (re)create a set and load its +// entries: an idempotent create (-exist, so a reload does not fail on the +// existing set), a flush (so a reload drops entries removed since the last +// write, making the entry list declarative), then one add per entry. +func (h *hookScript) ipsetLinesFor(set *AddressSet) []string { + fam := "inet" + if set.Family == IPv6 { + fam = "inet6" + } + cmd := h.ipsetCommand() + lines := []string{ + fmt.Sprintf("%s create %s %s family %s -exist", cmd, set.Name, set.Type.String(), fam), + fmt.Sprintf("%s flush %s", cmd, set.Name), + } + for _, e := range set.Entries { + lines = append(lines, fmt.Sprintf("%s add %s %s", cmd, set.Name, e)) + } + return lines +} + +// hookIPSetName returns the set a hook ipset line operates on, or "" when the +// line is not one of the library's ipset commands. Every such line names the set +// in its third field (`ipset ...`), whether the command is spelled +// bare or as a resolved path. +func hookIPSetName(line string) string { + f := strings.Fields(line) + if len(f) >= 3 && hookCmdIs(f[0], "ipset") { + return f[2] + } + return "" +} + +// cmdRefsSet reports whether a command line references name through an +// `-m set --match-set ` match, so a set is not removed out from under a +// rule that still uses it (the kernel enforces the same on a live destroy). +func cmdRefsSet(cmd, name string) bool { + f := strings.Fields(cmd) + for i := 0; i+1 < len(f); i++ { + if f[i] == "--match-set" && f[i+1] == name { + return true + } + } + return false +} + +// getAddressSets parses the sets the hook carries, in the order their create +// lines appear. An ipset is pinned to a single family, so each create yields one +// set and its add lines supply the entries; flush lines carry no state and are +// ignored. Add lines are applied after the scan so a hand-authored add above +// its create still counts. +func (h *hookScript) getAddressSets() ([]*AddressSet, error) { + fd, err := os.Open(h.hookPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = fd.Close() }() + // ipsetParseType is an IPTables method that ignores its receiver; a zero value + // reuses the same create-line parser the iptables backend uses. + ipt := &IPTables{} + sets := map[string]*AddressSet{} + var order []string + var adds [][2]string + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + f := strings.Fields(scanner.Text()) + if len(f) < 4 || !hookCmdIs(f[0], "ipset") { + continue + } + switch f[1] { + case "create": + // ipsetParseType scans a `create NAME family ...` slice from + // its third element, so drop the leading `ipset` word to line it up. + fam, typ := ipt.ipsetParseType(f[1:]) + sets[f[2]] = &AddressSet{Name: f[2], Family: fam, Type: typ} + order = append(order, f[2]) + case "add": + // A hand-authored add may carry trailing options (`timeout 300`, `-exist`); + // the entry itself is still the fourth field. Options are not modeled, so a + // rewrite of the set's block re-emits the entry without them. + adds = append(adds, [2]string{f[2], f[3]}) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + for _, a := range adds { + if s, ok := sets[a[0]]; ok { + s.Entries = append(s.Entries, a[1]) + } + } + out := make([]*AddressSet, 0, len(order)) + for _, n := range order { + out = append(out, sets[n]) + } + return out, nil +} + +// editAddressSet writes or removes a set's ipset lines in the hook. Adding drops +// any prior lines for the set and reinserts its block ahead of the first +// iptables/ip6tables line, so the set exists before any rule matches it; the +// write is idempotent. Removing drops the set's lines but refuses when a hook +// rule still references it. Every other hook line — user shell, rules, other +// sets — streams through to the staged rewrite untouched; it reports whether +// the hook changed. +func (h *hookScript) editAddressSet(set *AddressSet, remove bool) (bool, error) { + // hookIPSetName reports "" for every non-ipset line, so an unnamed set would + // match — and the drop filter below would strip — every rule and user line in + // the hook. + if set.Name == "" { + return false, fmt.Errorf("an address set requires a name") + } + // A missing hook scans as empty: an add creates it, a removal is a no-op. + fd, err := os.Open(h.hookPath) + if err != nil { + if !os.IsNotExist(err) { + return false, err + } + if remove { + return false, nil + } + } else { + defer func() { _ = fd.Close() }() + } + + af, err := newAtomicFile(h.hookPath, h.hookPerm) + if err != nil { + return false, err + } + defer af.Abort() + + block := h.ipsetLinesFor(set) + // pos counts the lines written so a no-op re-add can be recognized: the hook + // is unchanged only when the dropped block was contiguous (every drop saw + // the same pos), sat exactly at the reinsertion point, and matches the fresh + // block line for line. + pos := 0 + write := func(ls ...string) { + for _, l := range ls { + _, _ = fmt.Fprintln(af, l) + pos++ + } + } + // A freshly created hook gets a shebang; the firewall sources it as shell. + if fd == nil { + write("#!/bin/sh") + } + var droppedLines []string + firstDrop, contiguous := 0, true + inserted := false + insertPos := 0 + err = h.scanGroups(fd, func(g hookGroup) error { + // Drop any existing lines for this set (idempotent re-add; also the + // removal path). ipset lines are single-line groups, so every rule's + // attached comment stays with its rule. + if len(g.cmds) == 0 && hookIPSetName(g.raw[0]) == set.Name { + if droppedLines == nil { + firstDrop = pos + } else if pos != firstDrop { + contiguous = false + } + droppedLines = append(droppedLines, g.raw[0]) + return nil + } + if remove { + for _, cmd := range g.cmds { + if cmdRefsSet(cmd, set.Name) { + return fmt.Errorf("address set %q is in use by a rule", set.Name) + } + } + } + // Insert the set's block ahead of the first command-line group — the + // rule's attached comment included — keeping every ipset line before + // every rule line. + if !remove && !inserted && len(g.cmds) > 0 { + insertPos = pos + write(block...) + inserted = true + } + write(g.raw...) + return nil + }) + if err != nil { + return false, err + } + + if remove { + if droppedLines == nil { + return false, nil + } + return true, h.commit(af) + } + // Append the block when the hook holds no command line to insert it ahead of. + if !inserted { + insertPos = pos + write(block...) + } + if contiguous && insertPos == firstDrop && slices.Equal(droppedLines, block) { + return false, nil + } + return true, h.commit(af) +} + +// editAddressSetEntry adds or removes a single entry in an existing set by +// rewriting the set's block. The set must already exist in the hook. +func (h *hookScript) editAddressSetEntry(name, entry string, remove bool) (bool, error) { + sets, err := h.getAddressSets() + if err != nil { + return false, err + } + var target *AddressSet + for _, s := range sets { + if s.Name == name { + target = s + break + } + } + if target == nil { + return false, fmt.Errorf("address set %q not found", name) + } + if remove { + next := target.Entries[:0] + found := false + for _, e := range target.Entries { + if e == entry { + found = true + continue + } + next = append(next, e) + } + if !found { + return false, nil + } + target.Entries = next + } else { + for _, e := range target.Entries { + if e == entry { + return false, nil + } + } + target.Entries = append(target.Entries, entry) + } + return h.editAddressSet(target, false) +} + +// --- NAT rules (raw nat-table commands in the hook) -------------------------- +// +// csf.redirect holds exactly two destination-NAT shapes, so every other NAT rule +// — source NAT, an interface-bound or source-matched translation, a port +// range/list — is injected as a raw `iptables -t nat` command through the same +// hook that carries the filter rules. csf flushes the v4 nat table on every +// (re)start whenever the kernel provides one (its Config.pm probes `-t nat -L +// POSTROUTING` and sets NAT=1) and sources the pre-hook afterwards, so the +// injected lines are applied exactly once per load; the v6 nat flush is guarded +// by IPV6, the same hazard the filterNATFamiliesIPv6 narrowing guards. APF +// reuses these same NAT methods, binding a hookScript to each of its shell-sourced +// routing files (preroute.rules for destination NAT, postroute.rules for source +// NAT) instead of a single pre-hook — the file differs, the mechanism does not. + +// natLine encodes a family-concrete NAT rule row (an expandNATFamilies fan-out) +// as one raw nat-table command line. The prefix tag is not marshalled into the +// line — it rides as the script comment editNAT writes above it. Each +// marshalled line is re-tokenized and re-quoted shell-safely, as with +// linesForRows, because the hook is sourced by /bin/sh. +func (h *hookScript) natLine(r *NATRule) (string, error) { + fam := r.impliedFamily() + rc := *r + rc.Family = fam + ipt := &IPTables{} + // As in linesForRows, the borrowed encoder takes its own check here. + if err := rc.validate(); err != nil { + return "", err + } + spec, err := ipt.MarshalNATRule(&rc) + if err != nil { + return "", err + } + tokens, err := shlex.Split(spec, true) + if err != nil { + return "", err + } + for i, t := range tokens { + tokens[i] = shellSafeToken(t) + } + return h.command(fam) + " -t nat " + strings.Join(tokens, " "), nil +} + +// parseNATLine decodes a raw nat command line back into the NATRule it +// represents, reporting whether the line is one this backend recognizes. The +// iptables NAT parser derives HasPrefix from a comment tag embedded in the line +// (a line written by an older library version); scanGroups additionally +// marks it from the script comment above the line, which is the form this +// library writes. A `-t nat` line never doubles as a filter rule: parseLine's +// chain check (INPUT/OUTPUT/FORWARD) rejects it, so the two line kinds stay +// disjoint in the same hook. +func (h *hookScript) parseNATLine(line string) (*NATRule, bool) { + fam, args, ok := hookCmdFamily(line) + if !ok { + return nil, false + } + rest, ok := strings.CutPrefix(args, "-t nat ") + if !ok { + return nil, false + } + ipt := &IPTables{rulePrefix: h.rulePrefix} + r, err := ipt.UnmarshalNATRule(rest, fam) + if err != nil { + return nil, false + } + return r, true +} + +// getNATRules parses the raw nat-table rules the hook carries, each with its +// prefix flag resolved from the script comment above it or a tag embedded in +// the line. Every such line is returned, including any a user authored by hand, +// so the library reconciles the hook's real state. +func (h *hookScript) getNATRules() ([]*NATRule, error) { + fd, err := os.Open(h.hookPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = fd.Close() }() + var rules []*NATRule + err = h.scanGroups(fd, func(g hookGroup) error { + if g.nat != nil { + rules = append(rules, g.nat) + } + return nil + }) + return rules, err +} + +// editNAT adds or removes a NAT rule's raw nat command line(s) in the hook, +// mirroring edit for filter rules: an add writes only the families the backend +// enforces and is satisfied by an equivalent existing line — the same +// translation under EqualForRemoval, which stays family-aware so a +// family-scoped edit leaves an opposite-family twin alone — while a removal +// sweeps both families, taking each dropped line's attached comment with it. +// Every other hook line is preserved; it reports whether the hook changed. +func (h *hookScript) editNAT(r *NATRule, remove bool) (bool, error) { + // An add writes only the family rows the backend enforces; encode their lines + // up front so a marshalling error stages nothing. A removal marshals nothing: + // it matches scanned lines through EqualForRemoval, whose family-covering + // check already sweeps both families' lines for a family-agnostic rule. + var rows []*NATRule + var famLines []string + if !remove { + rows = filterNATFamiliesIPv6(h.ipv6Enabled, r) + famLines = make([]string, len(rows)) + for i, row := range rows { + line, err := h.natLine(row) + if err != nil { + return false, err + } + famLines[i] = line + } + } + + // A missing hook scans as empty: an add creates it, a removal is a no-op. + fd, err := os.Open(h.hookPath) + if err != nil { + if !os.IsNotExist(err) { + return false, err + } + if remove { + return false, nil + } + } else { + defer func() { _ = fd.Close() }() + } + + af, err := newAtomicFile(h.hookPath, h.hookPerm) + if err != nil { + return false, err + } + defer af.Abort() + // A freshly created hook gets a shebang; the firewall sources it as shell. + if fd == nil { + _, _ = fmt.Fprintln(af, "#!/bin/sh") + } + + if remove { + changed := false + err = h.scanGroups(fd, func(g hookGroup) error { + if g.nat != nil && g.nat.EqualForRemoval(r) { + changed = true + return nil + } + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + return nil + }) + if err != nil { + return false, err + } + if !changed { + return false, nil + } + return true, h.commit(af) + } + + // Copy the hook through, noting which family rows it already holds. + present := make([]bool, len(rows)) + err = h.scanGroups(fd, func(g hookGroup) error { + if g.nat != nil && g.nat.EqualForRemoval(r) { + for i, row := range rows { + if g.nat.impliedFamily() == row.impliedFamily() { + present[i] = true + } + } + } + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + return nil + }) + if err != nil { + return false, err + } + + // Append the family lines the hook does not already hold, each under the + // prefix-tag script comment (a NAT rule carries no user comment of its own). + changed := false + for i := range rows { + if present[i] { + continue + } + if h.rulePrefix != "" { + _, _ = fmt.Fprintln(af, "# "+h.rulePrefix) + } + _, _ = fmt.Fprintln(af, famLines[i]) + changed = true + } + if !changed { + return false, nil + } + return true, h.commit(af) +} diff --git a/hooks_linux_test.go b/hooks_linux_test.go new file mode 100644 index 0000000..c41ef8d --- /dev/null +++ b/hooks_linux_test.go @@ -0,0 +1,1111 @@ +package firewall + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// Two rules that are Equal (port-set order is not part of rule identity) must +// dedup against each other, so a second add is a no-op and a remove using a +// reordered port set still finds the rule. The hook script matches on the +// parsed rule, which the marshal/parse round trip normalizes. +func TestHookScriptPortOrderIdempotent(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + } + + // SCTP has no native CSF/APF config path, so a multi-port SCTP rule routes + // through the hook. These two differ only in port order, so they are Equal. + a := &Rule{Family: IPv4, Proto: SCTP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept} + b := &Rule{Family: IPv4, Proto: SCTP, Ports: []PortRange{{Start: 443}, {Start: 80}}, Action: Accept} + require.True(t, a.Equal(b, true), "the two rules must be Equal (order-independent)") + + changed, err := h.edit(a, false) + require.NoError(t, err) + require.True(t, changed) + + changed, err = h.edit(b, false) + require.NoError(t, err) + require.False(t, changed, "an Equal rule with reordered ports must not inject a duplicate") + + // Removing via the reordered form must still find and drop the rule. + changed, err = h.edit(b, true) + require.NoError(t, err) + require.True(t, changed, "removing an Equal rule with reordered ports must drop it") + + got, err := h.getRules() + require.NoError(t, err) + require.Empty(t, got, "the rule must be gone after removal") +} + +// A TCPUDP rule has no single iptables form — one line matches one -p — so the hook +// fans it out into a tcp line and a udp line, mirroring the tcp+udp fan-out csf/apf +// write in their native config. Both add and remove must fan out and never reject +// the rule for want of a concrete protocol. A Backup may hold a TCPUDP rule, so +// Restore's hook-copy clear must marshal it rather than abort the whole restore. +func TestHookScriptTCPUDPPortFansOut(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + } + + // Adding a TCPUDP port rule injects a tcp line and a udp line. + any := &Rule{Family: IPv4, Proto: TCPUDP, Port: 20, Action: Accept} + changed, err := h.edit(any, false) + require.NoError(t, err, "a TCPUDP port rule must marshal, not be rejected") + require.True(t, changed) + + got, err := h.getRules() + require.NoError(t, err) + require.Len(t, got, 2, "a TCPUDP port rule fans out into a tcp and a udp hook line") + protos := map[Protocol]bool{} + for _, g := range got { + protos[g.Proto] = true + } + require.True(t, protos[TCP] && protos[UDP], "the fan-out must cover both tcp and udp: %+v", got) + + // Removing the TCPUDP form clears both concrete copies in one call, without + // erroring on the port-without-concrete-protocol shape. + changed, err = h.edit(any, true) + require.NoError(t, err, "removing a TCPUDP port rule must not fail to marshal") + require.True(t, changed, "the TCPUDP remove must clear the tcp and udp copies") + + got, err = h.getRules() + require.NoError(t, err) + require.Empty(t, got, "both fanned-out copies must be gone after the TCPUDP remove") +} + +// A deny whose action differs from the CSF/APF config's STOP action has no native +// form (deny_hosts/csf.deny encode no action of their own), so those backends +// inject it through the hook, whose iptables rule carries the exact action. The +// hook must marshal and read back the precise action, not coerce it — otherwise a +// Reject deny would read back as Drop and churn on every Sync. +func TestHookScriptCarriesExactDenyAction(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + } + + for _, deny := range []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.31/32", Action: Reject}, + {Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.32/32", Action: Drop}, + } { + changed, err := h.edit(deny, false) + require.NoError(t, err) + require.True(t, changed, "the deny must be injected: %+v", deny) + + got, err := h.getRules() + require.NoError(t, err) + var match *Rule + for _, g := range got { + if g.Equal(deny, true) { + match = g + } + } + require.NotNil(t, match, "the deny must read back from the hook: %+v", deny) + require.Equal(t, deny.Action, match.Action, + "the hook must carry the deny's exact action, not coerce it: %+v", deny) + + changed, err = h.edit(deny, true) + require.NoError(t, err) + require.True(t, changed, "the deny must be removable: %+v", deny) + } +} + +// A hook written by an older library version embeds the comment in the command +// line via `-m comment`. The embedded text takes read precedence and its prefix +// tag still marks the rule, a re-add is satisfied by the legacy line, and a +// removal clears it — so an existing hook migrates without duplicate lines. +func TestHookLegacyEmbeddedCommentMigrates(t *testing.T) { + h := newTestHook(t) + r := &Rule{Family: IPv4, Proto: TCP, Port: 8443, Action: Accept, State: StateNew, Comment: "web tier"} + + // Recreate the legacy spelling: the current line with the comment tokens + // spliced back in ahead of the action, single-quoted as the old writer did. + lines, err := h.ruleLines(r, false) + require.NoError(t, err) + require.Len(t, lines, 1) + legacy := strings.Replace(lines[0], "-j ACCEPT", "-m comment --comment 'go_firewall web tier' -j ACCEPT", 1) + require.NoError(t, os.WriteFile(h.hookPath, []byte("#!/bin/sh\n"+legacy+"\n"), 0700)) + + got, err := h.getRules() + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, "web tier", got[0].Comment) + require.True(t, got[0].HasPrefix, "the embedded prefix tag must still mark the rule") + + changed, err := h.edit(r, false) + require.NoError(t, err) + require.False(t, changed, "the legacy line must satisfy the add") + + changed, err = h.edit(r, true) + require.NoError(t, err) + require.True(t, changed, "the legacy line must be removable") + got, err = h.getRules() + require.NoError(t, err) + require.Empty(t, got) +} + +// Comment attachment follows the trust-file scanners: a blank line detaches a +// comment from the rule below, a prefix tag starts a fresh block so a section +// header above it survives the rule's removal, and an embedded iptables comment +// keeps its text over the script comment while the prefix counts from either. +func TestHookScriptCommentAttachment(t *testing.T) { + h := newTestHook(t) + body := "#!/bin/sh\n" + + "# detached note\n" + + "\n" + + "iptables -A INPUT -p tcp -m tcp --dport 2020 -j ACCEPT\n" + + "# section header\n" + + "# go_firewall tagged\n" + + "iptables -A INPUT -p tcp -m tcp --dport 2021 -j ACCEPT\n" + + "# go_firewall script note\n" + + "iptables -A INPUT -p tcp -m tcp --dport 2022 -m comment --comment 'acme note' -j ACCEPT\n" + require.NoError(t, os.WriteFile(h.hookPath, []byte(body), 0700)) + + got, err := h.getRules() + require.NoError(t, err) + require.Len(t, got, 3) + byPort := map[uint16]*Rule{} + for _, g := range got { + byPort[g.Port] = g + } + require.Empty(t, byPort[2020].Comment, "a blank line must detach a comment from the rule below") + require.False(t, byPort[2020].HasPrefix) + require.Equal(t, "tagged", byPort[2021].Comment, "the prefix tag starts the rule's comment block") + require.True(t, byPort[2021].HasPrefix) + require.Equal(t, "acme note", byPort[2022].Comment, + "an embedded iptables comment must take precedence over the script comment") + require.True(t, byPort[2022].HasPrefix, "the prefix must count from the script comment too") + + // Removing the tagged rule drops its tag comment but keeps the section + // header above it and the detached note. + _, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 2021, Action: Accept}, true) + require.NoError(t, err) + data, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + require.Contains(t, string(data), "# section header") + require.Contains(t, string(data), "# detached note") + require.NotContains(t, string(data), "# go_firewall tagged") +} + +func TestHookScriptRoundTrip(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + ipv6Enabled: true, + } + + // A family-agnostic rule is injected for both v4 and v6 when the backend enforces + // IPv6. + lines, err := h.ruleLines(&Rule{Proto: TCP, Port: 8080, Action: Accept, State: StateNew}, false) + require.NoError(t, err) + require.Len(t, lines, 2) + require.True(t, strings.HasPrefix(lines[0], "iptables "), "want iptables line, got %q", lines[0]) + require.True(t, strings.HasPrefix(lines[1], "ip6tables "), "want ip6tables line, got %q", lines[1]) + + // Family-pinned rules covering each non-native feature round-trip through the + // hook. + rules := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Accept, State: StateNew | StateEstablished}, + {Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"}, + {Family: IPv4, Proto: TCP, Port: 443, Action: Accept, InInterface: "eth0"}, + {Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, + {Family: IPv4, Proto: TCP, Port: 25, Action: Drop, RateLimit: &RateLimit{Rate: 5, Unit: PerMinute, Burst: 3}}, + } + for _, r := range rules { + changed, err := h.edit(r, false) + require.NoError(t, err, "add %+v", *r) + require.True(t, changed, "expected add to change the script: %+v", *r) + } + + // Adding again is idempotent. + changed, err := h.edit(rules[0], false) + require.NoError(t, err) + require.False(t, changed, "expected a duplicate add to be a no-op") + + // The command lines live in the hook itself, under a single shebang. + hookData, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + require.Equal(t, 1, strings.Count(string(hookData), "#!/bin/sh"), "hook should carry one shebang") + require.Contains(t, string(hookData), "iptables ") + + // Every rule reads back equal (family ignored, as the hook stores per-family). + got, err := h.getRules() + require.NoError(t, err) + require.Len(t, got, len(rules)) + for _, want := range rules { + found := false + for _, g := range got { + if g.EqualBase(want, true) { + found = true + break + } + } + require.True(t, found, "rule not read back: %+v", *want) + } + + // The logged rule round-trips with its prefix intact. + for _, g := range got { + if g.Port == 80 { + require.True(t, g.Log, "expected the port 80 rule to be logged") + require.Equal(t, "web", g.LogPrefix) + } + } + + // Removing one drops it (both its LOG and action lines) and leaves the rest. + changed, err = h.edit(rules[1], true) + require.NoError(t, err) + require.True(t, changed) + got, err = h.getRules() + require.NoError(t, err) + require.Len(t, got, len(rules)-1) + for _, g := range got { + require.False(t, g.EqualBase(rules[1], true), "removed rule still present") + } + + // Removing an absent rule is a no-op. + changed, err = h.edit(rules[1], true) + require.NoError(t, err) + require.False(t, changed, "expected removing an absent rule to be a no-op") +} + +// Writing command lines into the existing hook must leave user-authored content +// untouched: arbitrary shell survives an add and a remove, and an iptables rule a +// user added by hand both survives edits and surfaces in getRules (the library +// reconciles the hook's actual state, not just the lines it wrote). +func TestHookPreservesUserContent(t *testing.T) { + dir := t.TempDir() + hookPath := filepath.Join(dir, "csfpre.sh") + userContent := "#!/bin/sh\n" + + "# operator's own pre-hook logic\n" + + "logger firewall reloading\n" + + "iptables -A INPUT -p tcp --dport 2222 -j ACCEPT\n" + require.NoError(t, os.WriteFile(hookPath, []byte(userContent), 0700)) + + h := &hookScript{rulePrefix: "go_firewall", hookPath: hookPath, hookPerm: 0700} + + // A hand-added iptables rule the library never wrote surfaces in getRules, + // reported as foreign (no prefix tag). + got, err := h.getRules() + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, uint16(2222), got[0].Port) + require.False(t, got[0].HasPrefix, "a user-authored rule must read back as foreign") + + // Adding our rule keeps every user line in place. + added := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, State: StateNew} + changed, err := h.edit(added, false) + require.NoError(t, err) + require.True(t, changed) + data, err := os.ReadFile(hookPath) + require.NoError(t, err) + require.Contains(t, string(data), "logger firewall reloading") + require.Contains(t, string(data), "iptables -A INPUT -p tcp --dport 2222 -j ACCEPT") + require.Equal(t, 1, strings.Count(string(data), "#!/bin/sh"), "must not add a second shebang") + + // Removing our rule leaves the user's shell and rule behind. + changed, err = h.edit(added, true) + require.NoError(t, err) + require.True(t, changed) + data, err = os.ReadFile(hookPath) + require.NoError(t, err) + require.Contains(t, string(data), "logger firewall reloading") + require.Contains(t, string(data), "iptables -A INPUT -p tcp --dport 2222 -j ACCEPT") + + // The user's rule still reads back after our churn. + got, err = h.getRules() + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, uint16(2222), got[0].Port) +} + +// A hook line is sourced by /bin/sh, so a comment or log prefix containing $ or a +// backtick must be single-quoted (a literal), not left in strconv.Quote's double +// quotes where the shell would expand it. And it must still parse back intact. +func TestHookShellSafeLogPrefix(t *testing.T) { + require.Equal(t, "-A", shellSafeToken("-A")) + require.Equal(t, "INPUT", shellSafeToken("INPUT")) + require.Equal(t, `'web $USER'`, shellSafeToken("web $USER")) + require.Equal(t, `'a'\''b'`, shellSafeToken("a'b")) + + h := &hookScript{rulePrefix: "myapp"} + lines, err := h.ruleLines(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, Log: true, LogPrefix: "drop $x"}, false) + require.NoError(t, err) + joined := strings.Join(lines, "\n") + require.NotContains(t, joined, `"drop $x"`, "a $-bearing prefix must not stay double-quoted for the shell") + require.Contains(t, joined, `'drop $x'`) + + found := false + for _, l := range lines { + if r, ok := h.parseLine(l); ok && r.Log { + require.Equal(t, "drop $x", r.LogPrefix) + found = true + } + } + require.True(t, found, "the log line must parse back to the original prefix") +} + +// A protocol CSF/APF cannot express natively (SCTP and the portless IP +// protocols) is routed through the raw-iptables hook and round-trips there. +func TestHookProtocolExtras(t *testing.T) { + for _, p := range []Protocol{SCTP, GRE, ESP, AH} { + require.True(t, hookOnlyProto(p), "%s should route through the hook", p) + require.True(t, ruleNeedsHook(&Rule{Proto: p, Action: Accept})) + } + require.False(t, hookOnlyProto(TCP)) + require.False(t, ruleNeedsHook(&Rule{Proto: TCP, Port: 22, Action: Accept})) + + h := &hookScript{hookPath: "/tmp/unused", rulePrefix: "go_firewall"} + cases := []*Rule{ + {Family: IPv4, Proto: GRE, Action: Accept}, + {Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept}, + } + for _, orig := range cases { + lines, err := h.ruleLines(orig, false) + require.NoError(t, err, "%+v", orig) + require.NotEmpty(t, lines) + got, ok := h.parseLine(lines[len(lines)-1]) + require.True(t, ok, "line %q", lines[len(lines)-1]) + require.True(t, got.EqualBase(orig, true), "want %+v got %+v", orig, got) + } +} + +func newTestHook(t *testing.T) *hookScript { + t.Helper() + return &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(t.TempDir(), "csfpre.sh"), + hookPerm: 0700, + } +} + +// A set written to the hook round-trips through getAddressSets with its family, +// type and entries intact, for both IPv4 and IPv6, and re-adding an identical set +// is idempotent. +func TestHookAddressSetRoundTrip(t *testing.T) { + h := newTestHook(t) + + v4 := &AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashNet, Entries: []string{"192.0.2.0/24", "198.51.100.7"}} + changed, err := h.editAddressSet(v4, false) + require.NoError(t, err) + require.True(t, changed) + + // Re-adding the identical set does not rewrite the hook. + changed, err = h.editAddressSet(v4, false) + require.NoError(t, err) + require.False(t, changed, "re-adding an identical set must be idempotent") + + v6 := &AddressSet{Name: "v6drop", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}} + _, err = h.editAddressSet(v6, false) + require.NoError(t, err) + + sets, err := h.getAddressSets() + require.NoError(t, err) + require.Len(t, sets, 2) + byName := map[string]*AddressSet{} + for _, s := range sets { + byName[s.Name] = s + } + require.Equal(t, IPv4, byName["blocklist"].Family) + require.Equal(t, SetHashNet, byName["blocklist"].Type) + require.ElementsMatch(t, []string{"192.0.2.0/24", "198.51.100.7"}, byName["blocklist"].Entries) + require.Equal(t, IPv6, byName["v6drop"].Family) + require.Equal(t, SetHashIP, byName["v6drop"].Type) + require.Equal(t, []string{"2001:db8::1"}, byName["v6drop"].Entries) +} + +// The ipset commands for a set must be written ahead of any rule that references +// it, even when the rule was added first, so the set exists when the hook runs. +func TestHookAddressSetOrderedBeforeRules(t *testing.T) { + h := newTestHook(t) + + // Add the referencing rule first — edit appends it at the end of the hook. + _, err := h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, false) + require.NoError(t, err) + // Then add the set; its block must be spliced in before the rule line. + _, err = h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false) + require.NoError(t, err) + + data, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + body := string(data) + ipsetAt := strings.Index(body, "ipset create blocklist") + ruleAt := strings.Index(body, "--match-set blocklist") + require.GreaterOrEqual(t, ipsetAt, 0, "the create command must be present") + require.GreaterOrEqual(t, ruleAt, 0, "the referencing rule must be present") + require.Less(t, ipsetAt, ruleAt, "ipset commands must precede the rule that references the set") +} + +// Removing a set a rule still references is refused (the kernel enforces the same +// on a live destroy); once the rule is gone the removal succeeds. +func TestHookAddressSetInUseGuard(t *testing.T) { + h := newTestHook(t) + _, err := h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false) + require.NoError(t, err) + _, err = h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, false) + require.NoError(t, err) + + _, err = h.editAddressSet(&AddressSet{Name: "blocklist"}, true) + require.Error(t, err, "removing a set a rule references must fail") + + _, err = h.edit(&Rule{Family: IPv4, Source: "blocklist", Action: Drop}, true) + require.NoError(t, err) + changed, err := h.editAddressSet(&AddressSet{Name: "blocklist"}, true) + require.NoError(t, err) + require.True(t, changed) + sets, err := h.getAddressSets() + require.NoError(t, err) + require.Empty(t, sets, "the set must be gone after removal") +} + +// Entry edits add and remove a single address in an existing set idempotently, +// and editing a set that does not exist is an error. +func TestHookAddressSetEntryEdits(t *testing.T) { + h := newTestHook(t) + _, err := h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false) + require.NoError(t, err) + + changed, err := h.editAddressSetEntry("blocklist", "203.0.113.9", false) + require.NoError(t, err) + require.True(t, changed) + changed, err = h.editAddressSetEntry("blocklist", "203.0.113.9", false) + require.NoError(t, err) + require.False(t, changed, "adding an existing entry must be idempotent") + + sets, err := h.getAddressSets() + require.NoError(t, err) + require.Len(t, sets, 1) + require.ElementsMatch(t, []string{"203.0.113.5", "203.0.113.9"}, sets[0].Entries) + + changed, err = h.editAddressSetEntry("blocklist", "203.0.113.5", true) + require.NoError(t, err) + require.True(t, changed) + sets, err = h.getAddressSets() + require.NoError(t, err) + require.Equal(t, []string{"203.0.113.9"}, sets[0].Entries) + + _, err = h.editAddressSetEntry("missing", "1.2.3.4", false) + require.Error(t, err, "editing an entry in a set that does not exist must fail") +} + +// With the backend's own IPv6 handling off, a family-agnostic rule must be injected +// as an IPv4 line only. The pre-hook runs on every (re)load regardless, but csf/apf +// never flush ip6tables while IPv6 is disabled, so an injected ip6tables line would +// be re-appended on each reload and would outlive its removal from the hook. The +// AddRule IPv6 gate only stops a *concrete* IPv6 rule; a FamilyAny rule reaches +// the hook and must be narrowed here instead. +func TestHookScriptIPv6DisabledSkipsV6Family(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + } + + anyFam := &Rule{Proto: TCP, Port: 8080, Action: Accept, State: StateNew} + lines, err := h.ruleLines(anyFam, false) + require.NoError(t, err) + require.Len(t, lines, 1, "a family-agnostic rule must not be written for ipv6 when ipv6 is off") + require.True(t, strings.HasPrefix(lines[0], "iptables "), "want an iptables line, got %q", lines[0]) + + // It is written to the hook the same way, so no ip6tables command is ever injected. + changed, err := h.edit(anyFam, false) + require.NoError(t, err) + require.True(t, changed) + data, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + require.NotContains(t, string(data), "ip6tables ", + "an ip6tables line csf/apf never flush must not be injected while ipv6 is off") + + // A rule pinned to a concrete family keeps it: the AddRule IPv6 gate stops a + // fresh concrete-IPv6 add, and Restore bypasses that gate on purpose to reproduce a + // snapshot's entries verbatim, so the hook must still be able to render one. + v6 := &Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Accept, State: StateNew} + lines, err = h.ruleLines(v6, false) + require.NoError(t, err) + require.Len(t, lines, 1) + require.True(t, strings.HasPrefix(lines[0], "ip6tables "), "want an ip6tables line, got %q", lines[0]) +} + +// Switching IPv6 off must not strand the ip6tables lines written while it was on: +// removal sweeps both families even though an add only writes the enforced one. +func TestHookScriptRemoveSweepsV6AfterIPv6Disabled(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "csfpre.sh") + + // Written while the backend's IPv6 handling was on: an iptables and an ip6tables line. + on := &hookScript{rulePrefix: "go_firewall", hookPath: path, hookPerm: 0700, ipv6Enabled: true} + rule := &Rule{Proto: TCP, Port: 8080, Action: Accept, State: StateNew} + changed, err := on.edit(rule, false) + require.NoError(t, err) + require.True(t, changed) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(data), "ip6tables ") + + // IPv6 is now off. Removing the same rule must still clear the stale ip6tables line. + off := &hookScript{rulePrefix: "go_firewall", hookPath: path, hookPerm: 0700} + changed, err = off.edit(rule, true) + require.NoError(t, err) + require.True(t, changed) + data, err = os.ReadFile(path) + require.NoError(t, err) + require.NotContains(t, string(data), "ip6tables ", + "a stale ip6tables line must be swept on removal, not stranded in the hook") + require.NotContains(t, string(data), "iptables -A ") +} + +// An unnamed set must be refused outright: hookIPSetName reports "" for every +// non-ipset line, so an empty name would match — and a rewrite would drop — every +// rule and user-authored line in the hook. +func TestHookAddressSetEmptyNamePreservesHook(t *testing.T) { + h := newTestHook(t) + _, err := h.editAddressSet(&AddressSet{Name: "keepme", Family: IPv4, Type: SetHashIP, Entries: []string{"192.0.2.1"}}, false) + require.NoError(t, err) + _, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 2299, Action: Accept, State: StateNew}, false) + require.NoError(t, err) + before, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + + for _, remove := range []bool{true, false} { + changed, err := h.editAddressSet(&AddressSet{Name: ""}, remove) + require.Error(t, err, "an unnamed set must be refused (remove=%v)", remove) + require.False(t, changed) + } + after, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + require.Equal(t, string(before), string(after), "the hook must be untouched after a refused edit") +} + +// A logged rule and its unlogged twin are distinct rules: removing one must not +// strip the other's lines. The LOG line and its action line are matched as the one +// logged rule they encode, so the unlogged target matches neither. +func TestHookRemoveLoggedAndUnloggedAreDistinct(t *testing.T) { + logged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"} + unlogged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, State: StateNew} + + // Hook holds only the logged pair; removing the unlogged twin is a no-op. + h := newTestHook(t) + _, err := h.edit(logged, false) + require.NoError(t, err) + unloggedTwin := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept} + changed, err := h.edit(unloggedTwin, true) + require.NoError(t, err) + require.False(t, changed, "removing the unlogged twin must not touch the logged pair") + rules, err := h.getRules() + require.NoError(t, err) + require.Len(t, rules, 1) + require.True(t, rules[0].Log, "the logged rule must survive intact") + + // Hook holds only the unlogged rule; removing the logged twin is a no-op. + h2 := newTestHook(t) + _, err = h2.edit(unlogged, false) + require.NoError(t, err) + changed, err = h2.edit(logged, true) + require.NoError(t, err) + require.False(t, changed, "removing the logged twin must not touch the unlogged rule") + + // Removing the logged rule itself clears both of its lines. + changed, err = h.edit(logged, true) + require.NoError(t, err) + require.True(t, changed) + rules, err = h.getRules() + require.NoError(t, err) + require.Empty(t, rules) + data, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + require.NotContains(t, string(data), "-j LOG", "the LOG line must be removed with its action line") +} + +// A stray LOG line whose action partner was hand-edited away still belongs to the +// logged rule, so removing that rule sweeps it rather than stranding a live kernel +// LOG rule the library does not report. +func TestHookRemoveSweepsOrphanLogLine(t *testing.T) { + h := newTestHook(t) + logged := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"} + _, err := h.edit(logged, false) + require.NoError(t, err) + + // Hand-remove the action line, leaving the LOG line orphaned. + data, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + var kept []string + for _, l := range strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") { + if strings.Contains(l, "-j ACCEPT") { + continue + } + kept = append(kept, l) + } + require.NoError(t, os.WriteFile(h.hookPath, []byte(strings.Join(kept, "\n")+"\n"), 0700)) + + changed, err := h.edit(logged, true) + require.NoError(t, err) + require.True(t, changed, "the orphan LOG line must be swept") + data, err = os.ReadFile(h.hookPath) + require.NoError(t, err) + require.NotContains(t, string(data), "-j LOG") +} + +// A hand-authored add line may carry trailing options (`timeout 300`, `-exist`); +// the entry is still captured, so a rewrite of the set's block re-emits it instead +// of silently dropping the user's entry. +func TestHookAddressSetOptionedAddLineKeepsEntry(t *testing.T) { + h := newTestHook(t) + body := "#!/bin/sh\n" + + "ipset create blocklist hash:ip family inet -exist\n" + + "ipset flush blocklist\n" + + "ipset add blocklist 203.0.113.9 timeout 300\n" + require.NoError(t, os.WriteFile(h.hookPath, []byte(body), 0700)) + + sets, err := h.getAddressSets() + require.NoError(t, err) + require.Len(t, sets, 1) + require.Equal(t, []string{"203.0.113.9"}, sets[0].Entries) + + // A rewrite of the block (adding a second entry) keeps the optioned entry. + changed, err := h.editAddressSetEntry("blocklist", "198.51.100.7", false) + require.NoError(t, err) + require.True(t, changed) + sets, err = h.getAddressSets() + require.NoError(t, err) + require.ElementsMatch(t, []string{"203.0.113.9", "198.51.100.7"}, sets[0].Entries) +} + +// A family-agnostic set-referencing rule is pinned to the set's family: an ipset +// is single-family, so the opposite-family line would fail every time the firewall +// sources the hook. A reference to a set the hook does not carry is an error, and +// an IPv6 set is unusable while the backend's IPv6 handling is off. +func TestHookSetRefPinsFamilyAnyToSetFamily(t *testing.T) { + h := newTestHook(t) + h.ipv6Enabled = true + _, err := h.editAddressSet(&AddressSet{Name: "v6drop", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}}, false) + require.NoError(t, err) + + lines, err := h.ruleLines(&Rule{Source: "v6drop", Action: Drop}, false) + require.NoError(t, err) + require.Len(t, lines, 1, "a family-agnostic set rule must be written for the set's family only") + require.True(t, strings.HasPrefix(lines[0], "ip6tables "), "want an ip6tables line, got %q", lines[0]) + + _, err = h.ruleLines(&Rule{Source: "missing", Action: Drop}, false) + require.Error(t, err, "a reference to an unknown set must be an error") + + h.ipv6Enabled = false + _, err = h.ruleLines(&Rule{Source: "v6drop", Action: Drop}, false) + require.ErrorIs(t, err, ErrUnsupported, "an IPv6 set is unusable while IPv6 is off") +} + +// The hook carries NAT rules csf.redirect cannot hold as raw `-t nat` command +// lines: an add round-trips through getNATRules, a re-add is idempotent, and a +// removal drops the line while leaving filter rules and user shell in place. +func TestHookScriptNATRoundTrip(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + ipv6Enabled: true, + } + + cases := []*NATRule{ + {Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"}, + {Kind: Masquerade, Family: IPv4, Interface: "eth1"}, + {Kind: DNAT, Family: IPv4, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "10.0.0.5"}, + {Kind: Redirect, Family: IPv4, Proto: TCP, Port: 8080, Source: "192.0.2.0/24", ToPort: 80}, + } + for _, r := range cases { + changed, err := h.editNAT(r, false) + require.NoError(t, err, "add %+v", *r) + require.True(t, changed) + + changed, err = h.editNAT(r, false) + require.NoError(t, err) + require.False(t, changed, "re-adding %+v must be idempotent", *r) + + got, err := h.getNATRules() + require.NoError(t, err) + require.Len(t, got, 1, "the added rule must read back exactly once") + require.True(t, got[0].Equal(r), "read-back mismatch: want %+v got %+v", *r, *got[0]) + require.True(t, got[0].HasPrefix, "a hook NAT line this library wrote must report HasPrefix") + + changed, err = h.editNAT(r, true) + require.NoError(t, err) + require.True(t, changed, "removal must drop %+v", *r) + got, err = h.getNATRules() + require.NoError(t, err) + require.Empty(t, got, "the rule must be gone after removal") + } +} + +// A family-agnostic NAT rule fans out into an iptables and an ip6tables line +// with IPv6 on, narrows to IPv4 with it off, and a removal sweeps both families +// either way so a stale v6 line does not survive an IPv6 switch-off. +func TestHookScriptNATFamilyFanOut(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + ipv6Enabled: true, + } + + masq := &NATRule{Kind: Masquerade, Interface: "eth1"} + _, err := h.editNAT(masq, false) + require.NoError(t, err) + data, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + require.Contains(t, string(data), "iptables -t nat -A POSTROUTING") + require.Contains(t, string(data), "ip6tables -t nat -A POSTROUTING") + + // One family-agnostic removal clears both lines. + changed, err := h.editNAT(masq, true) + require.NoError(t, err) + require.True(t, changed) + got, err := h.getNATRules() + require.NoError(t, err) + require.Empty(t, got) + + // With IPv6 off the write narrows to IPv4 only. + h.ipv6Enabled = false + _, err = h.editNAT(masq, false) + require.NoError(t, err) + data, err = os.ReadFile(h.hookPath) + require.NoError(t, err) + require.Contains(t, string(data), "iptables -t nat -A POSTROUTING") + require.NotContains(t, string(data), "ip6tables -t nat", + "a family-agnostic write must narrow to IPv4 while IPv6 is off") + + // A stale v6 line (written while IPv6 was on, or by hand) is still swept. + v6 := *masq + v6.Family = IPv6 + v6line, err := h.natLine(&v6) + require.NoError(t, err) + require.NoError(t, os.WriteFile(h.hookPath, []byte("#!/bin/sh\n"+v6line+"\n"), 0700)) + changed, err = h.editNAT(masq, true) + require.NoError(t, err) + require.True(t, changed, "removal must sweep the stale IPv6 line even with IPv6 off") + got, err = h.getNATRules() + require.NoError(t, err) + require.Empty(t, got) +} + +// NAT lines share the hook with filter rules, ipset commands and user shell; +// each kind must be read by its own parser only and edits must leave the others +// byte-for-byte in place. A hand-added equivalent NAT line (different comment) +// satisfies an add and is cleared by a removal, mirroring filter-rule edits. +func TestHookScriptNATCoexistsWithFilterLines(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + ipv6Enabled: true, + } + + filter := &Rule{Family: IPv4, Proto: TCP, Port: 22, State: StateEstablished, Action: Accept} + _, err := h.edit(filter, false) + require.NoError(t, err) + snat := &NATRule{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"} + _, err = h.editNAT(snat, false) + require.NoError(t, err) + + // Each parser sees only its own lines. + frules, err := h.getRules() + require.NoError(t, err) + require.Len(t, frules, 1, "the NAT line must not surface as a filter rule") + nrules, err := h.getNATRules() + require.NoError(t, err) + require.Len(t, nrules, 1, "the filter line must not surface as a NAT rule") + + // Removing the NAT rule leaves the filter rule in place, and vice versa. + _, err = h.editNAT(snat, true) + require.NoError(t, err) + frules, err = h.getRules() + require.NoError(t, err) + require.Len(t, frules, 1, "a NAT removal must not touch filter lines") + + // A hand-added equivalent line under a different comment dedups an add and is + // cleared by a removal: the comment is not part of rule identity. + foreign := &hookScript{rulePrefix: "acme", hookPath: h.hookPath, hookPerm: 0700, ipv6Enabled: true} + _, err = foreign.editNAT(snat, false) + require.NoError(t, err) + changed, err := h.editNAT(snat, false) + require.NoError(t, err) + require.False(t, changed, "an equivalent hand-added NAT line must satisfy the add") + changed, err = h.editNAT(snat, true) + require.NoError(t, err) + require.True(t, changed, "removal must clear the equivalent hand-added NAT line") + nrules, err = h.getNATRules() + require.NoError(t, err) + require.Empty(t, nrules) +} + +// Set-family resolution asks the hook's own ipset lines first — sets are staged +// there and created when the firewall reloads, so the hook is what the rule will +// actually match against, and an unrelated live set of the same name must not +// shadow it — and falls back to the live kernel for a set that exists only +// there. A set found in neither place errors. +func TestHookScriptSetRefFamilyDeclaredFirst(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + ipv6Enabled: true, + } + + prev := ipsetLiveFamily + t.Cleanup(func() { ipsetLiveFamily = prev }) + // The kernel reports inet6 for both names; only "liveset" is ever declared in + // the hook, so the two arms below are told apart by precedence alone. + ipsetLiveFamily = func(name string) (Family, bool, error) { + if name == "liveset" || name == "hookset" { + return IPv6, true, nil + } + return FamilyAny, false, nil + } + + // Live only: resolved from the kernel, since the hook declares nothing. + lines, err := h.ruleLines(&Rule{Source: "liveset", Proto: TCP, Port: 22, Action: Accept}, false) + require.NoError(t, err) + require.Len(t, lines, 1) + require.True(t, strings.HasPrefix(lines[0], "ip6tables "), + "a live inet6 set must pin the rule to ip6tables, got %q", lines[0]) + + // Declared in the hook (just written, firewall not reloaded yet): the hook + // supplies the family even though a live set of that name says otherwise. + set := &AddressSet{Name: "hookset", Family: IPv4, Type: SetHashIP, Entries: []string{"192.0.2.1"}} + changed, err := h.editAddressSet(set, false) + require.NoError(t, err) + require.True(t, changed) + lines, err = h.ruleLines(&Rule{Source: "hookset", Proto: TCP, Port: 22, Action: Accept}, false) + require.NoError(t, err) + require.Len(t, lines, 1) + require.True(t, strings.HasPrefix(lines[0], "iptables "), + "a set staged inet in the hook must pin the rule to iptables even when a live set of the same name is inet6, got %q", lines[0]) + + // Known nowhere: an error, not a guessed family. + _, err = h.ruleLines(&Rule{Source: "ghost", Proto: TCP, Port: 22, Action: Accept}, false) + require.ErrorContains(t, err, `"ghost"`) +} + +// Every line the hook writes — rule, NAT and ipset — must invoke the resolved +// absolute path of its command, so the hook applies even when the firewall sources +// it with a PATH that omits the sbin directories. The lines still read back as the +// rules they encode. +func TestHookResolvedCommandPaths(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + ipv6Enabled: true, + ip4Cmd: "/usr/sbin/iptables", + ip6Cmd: "/usr/sbin/ip6tables", + ipsetCmd: "/usr/sbin/ipset", + } + + _, err := h.editAddressSet(&AddressSet{Name: "blocklist", Family: IPv4, Type: SetHashIP, Entries: []string{"203.0.113.5"}}, false) + require.NoError(t, err) + rule := &Rule{Family: IPv6, Proto: TCP, Port: 443, Action: Accept, State: StateNew} + _, err = h.edit(rule, false) + require.NoError(t, err) + nat := &NATRule{Family: IPv4, Kind: Masquerade, Interface: "eth1"} + _, err = h.editNAT(nat, false) + require.NoError(t, err) + + data, err := os.ReadFile(h.hookPath) + require.NoError(t, err) + body := string(data) + require.Contains(t, body, "/usr/sbin/ipset create blocklist") + require.Contains(t, body, "/usr/sbin/ip6tables -A INPUT") + require.Contains(t, body, "/usr/sbin/iptables -t nat -A POSTROUTING") + for _, line := range strings.Split(body, "\n") { + require.False(t, strings.HasPrefix(line, "iptables ") || strings.HasPrefix(line, "ip6tables ") || + strings.HasPrefix(line, "ipset "), "line must invoke a resolved path, got %q", line) + } + + // A path-spelled line parses back into the rule it encodes. + rules, err := h.getRules() + require.NoError(t, err) + require.Len(t, rules, 1) + require.True(t, rules[0].Equal(rule, true)) + require.True(t, rules[0].HasPrefix) + nats, err := h.getNATRules() + require.NoError(t, err) + require.Len(t, nats, 1) + require.True(t, nats[0].EqualForRemoval(nat)) + sets, err := h.getAddressSets() + require.NoError(t, err) + require.Len(t, sets, 1) + require.Equal(t, []string{"203.0.113.5"}, sets[0].Entries) +} + +// A command may be spelled any way in an existing hook — bare, under a path that +// differs from the one this manager resolved, quoted, or as an +// update-alternatives variant — and must still read back as the rule it encodes +// and satisfy a resolved-path hook's adds and removals, so a reconcile neither +// duplicates the line nor churns the hook. +func TestHookCommandSpellingsSatisfyResolvedHook(t *testing.T) { + dir := t.TempDir() + hookPath := filepath.Join(dir, "csfpre.sh") + require.NoError(t, os.WriteFile(hookPath, []byte("#!/bin/sh\n"+ + "iptables -A INPUT -p tcp -m tcp --dport 2222 -j ACCEPT\n"+ + "/sbin/iptables -A INPUT -p tcp -m tcp --dport 2223 -j ACCEPT\n"+ + "iptables-nft -A INPUT -p tcp -m tcp --dport 2224 -j ACCEPT\n"+ + "'/sbin/ip6tables' -A INPUT -p tcp -m tcp --dport 2225 -j ACCEPT\n"+ + "/sbin/ipset create blocklist hash:ip family inet -exist\n"+ + "ipset flush blocklist\n"+ + "/sbin/ipset add blocklist 203.0.113.5\n"+ + "/sbin/iptables -t nat -A POSTROUTING -o eth1 -j MASQUERADE\n"), 0700)) + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: hookPath, + hookPerm: 0700, + ip4Cmd: "/usr/sbin/iptables", + ip6Cmd: "/usr/sbin/ip6tables", + ipsetCmd: "/usr/sbin/ipset", + } + + // Every spelling reads back, with the family its command selects. + rules, err := h.getRules() + require.NoError(t, err) + byPort := map[uint16]*Rule{} + for _, r := range rules { + byPort[r.Port] = r + } + require.Len(t, byPort, 4) + for _, port := range []uint16{2222, 2223, 2224} { + require.Equal(t, IPv4, byPort[port].Family, "port %d", port) + } + require.Equal(t, IPv6, byPort[2225].Family, "a quoted ip6tables path selects IPv6") + nats, err := h.getNATRules() + require.NoError(t, err) + require.Len(t, nats, 1) + sets, err := h.getAddressSets() + require.NoError(t, err) + require.Len(t, sets, 1) + require.Equal(t, []string{"203.0.113.5"}, sets[0].Entries) + + // Re-adding those rules is a no-op: matching is on the parsed rule, not the + // command spelling, so the hook is not rewritten with duplicate lines. + for _, port := range []uint16{2222, 2223, 2224} { + changed, err := h.edit(&Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept}, false) + require.NoError(t, err) + require.False(t, changed, "an existing line for port %d must satisfy the add", port) + } + changed, err := h.editNAT(&NATRule{Family: IPv4, Kind: Masquerade, Interface: "eth1"}, false) + require.NoError(t, err) + require.False(t, changed, "an existing path-spelled nat line must satisfy the add") + + // Removal finds a differently spelled line too. + changed, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 2223, Action: Accept}, true) + require.NoError(t, err) + require.True(t, changed) + data, err := os.ReadFile(hookPath) + require.NoError(t, err) + require.NotContains(t, string(data), "--dport 2223") + require.Contains(t, string(data), "--dport 2222", "an unrelated line must survive") +} + +// resolveBinary — shared by the hook lines and every command the backends run — +// prefers PATH, falls back to the standard install directories when PATH misses +// the tool, and leaves a tool it cannot find bare (reporting not-found) so exec or +// the shell resolves it. +func TestResolveBinary(t *testing.T) { + dir := t.TempDir() + onPath := filepath.Join(dir, "gofwpathbin") + require.NoError(t, os.WriteFile(onPath, []byte("#!/bin/sh\n"), 0755)) + sbin := t.TempDir() + offPath := filepath.Join(sbin, "gofwsbinbin") + require.NoError(t, os.WriteFile(offPath, []byte("#!/bin/sh\n"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(sbin, "gofwnoexecbin"), []byte("#!/bin/sh\n"), 0644)) + + saved := binSearchDirs + t.Cleanup(func() { binSearchDirs = saved }) + binSearchDirs = []string{sbin} + t.Setenv("PATH", dir) + + got, ok := resolveBinary("gofwpathbin") + require.True(t, ok) + require.Equal(t, onPath, got) + got, ok = resolveBinary("gofwsbinbin") + require.True(t, ok, "a tool off PATH resolves from the install directories") + require.Equal(t, offPath, got) + got, ok = resolveBinary("gofwnoexecbin") + require.False(t, ok, "a non-executable file is not a tool") + require.Equal(t, "gofwnoexecbin", got) + got, ok = resolveBinary("gofwabsentbin") + require.False(t, ok, "an unresolvable tool stays bare") + require.Equal(t, "gofwabsentbin", got) + + // An explicit path is taken as given, so a caller can pin a tool. + got, ok = resolveBinary("/opt/sbin/gofwabsentbin") + require.True(t, ok) + require.Equal(t, "/opt/sbin/gofwabsentbin", got) + + // The hook wrapper reports the same path, bare when unresolvable. + require.Equal(t, offPath, resolveHookBinary("gofwsbinbin")) + require.Equal(t, "gofwabsentbin", resolveHookBinary("gofwabsentbin")) +} + +// TestHookStaysExecutable verifies a hook the firewall already ships stays +// runnable after the library edits it. apf installs hook_pre.sh non-executable +// and only runs it when the execute bit is set, and an atomic write otherwise +// preserves the existing mode — so without this the injected lines read back +// from the file correctly while never reaching the kernel. The rest of the mode +// is left alone. +func TestHookStaysExecutable(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hook_pre.sh") + // The shipped file: readable by owner and group, executable by neither. + require.NoError(t, os.WriteFile(path, []byte("#!/bin/bash\n"), 0640)) + + h := &hookScript{rulePrefix: "go_firewall", hookPath: path, hookPerm: 0750} + r := &Rule{Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept} + changed, err := h.edit(r, false) + require.NoError(t, err) + require.True(t, changed) + + fi, err := os.Stat(path) + require.NoError(t, err) + require.EqualValues(t, 0740, fi.Mode().Perm(), + "only the owner-execute bit is added; the rest of the shipped mode is kept") + + // A hook already executable keeps exactly the mode it had. + require.NoError(t, os.Chmod(path, 0755)) + _, err = h.edit(&Rule{Family: IPv4, Proto: SCTP, Port: 9001, Action: Accept}, false) + require.NoError(t, err) + fi, err = os.Stat(path) + require.NoError(t, err) + require.EqualValues(t, 0755, fi.Mode().Perm()) +} + +// TestHookNewFileUsesBackendMode verifies a hook the library creates itself still +// gets the backend's own mode (csf's csfpre.sh does not ship with the product). +func TestHookNewFileUsesBackendMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "csfpre.sh") + h := &hookScript{rulePrefix: "go_firewall", hookPath: path, hookPerm: 0700} + + changed, err := h.edit(&Rule{Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept}, false) + require.NoError(t, err) + require.True(t, changed) + + fi, err := os.Stat(path) + require.NoError(t, err) + require.EqualValues(t, 0700, fi.Mode().Perm()) +} diff --git a/integration_darwin_test.go b/integration_darwin_test.go new file mode 100644 index 0000000..669ad3c --- /dev/null +++ b/integration_darwin_test.go @@ -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) }}, + }) +} diff --git a/integration_freebsd_test.go b/integration_freebsd_test.go new file mode 100644 index 0000000..522eb90 --- /dev/null +++ b/integration_freebsd_test.go @@ -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) }}, + }) +} diff --git a/integration_linux_test.go b/integration_linux_test.go new file mode 100644 index 0000000..cac421b --- /dev/null +++ b/integration_linux_test.go @@ -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 +} diff --git a/integration_nohook_test.go b/integration_nohook_test.go new file mode 100644 index 0000000..3732f1e --- /dev/null +++ b/integration_nohook_test.go @@ -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 +} diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..049de77 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,2473 @@ +//go:build integration + +// Package firewall integration tests exercise the real firewall backends +// end-to-end (add a rule, read it back, remove it) rather than the marshal +// helpers the other _test.go files cover. They are gated behind the `integration` +// build tag so a normal `go test ./...` never touches a live firewall; run them +// with `go test -tags integration`. +// +// This file holds the platform-independent core — the capability-driven +// runManagerSuite and its helpers. Each OS has an integration__test.go with a +// TestIntegration that lists the backends available there (nft/iptables/firewalld/ +// ufw/csf/apf on Linux, pf on freebsd/darwin, wf on windows) and hands them to +// runIntegration. The suite is capability-driven: for each backend it inspects +// Capabilities() and exercises exactly the features that backend advertises, +// skipping the rest. +// +// These tests need privileges and the backend's real tooling, and they mutate the +// live firewall state of the machine they run on, so they are meant to run inside +// the throwaway VMs/containers under test/integration/, not on a workstation. Set +// FIREWALL_BACKEND to target one backend (a construction failure is then fatal, +// since the environment is expected to provide it); leave it unset to run whatever +// backends are present, skipping the rest. +package firewall + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// integrationPrefix namespaces every rule, set and table this suite creates so +// its writes are distinguishable from anything else on the box and, combined with +// the isolated container netns, are safe to clean up. +const integrationPrefix = "gofwit" + +// backendFactory pairs a backend's name with its constructor so a platform's +// TestIntegration can build them from a single table. +type backendFactory struct { + name string + new func(ctx context.Context, rulePrefix string) (Manager, error) +} + +// foreignSeed is a rule planted out of band with a backend's own tooling, standing +// in for a rule a human operator added, plus what the library should observe for +// it. Each platform's foreignSeeder produces one; see integration_linux_test.go. +type foreignSeed struct { + // rule is what GetRules should report for the seeded rule. + rule *Rule + // hasPrefix is what the informational HasPrefix flag should read: false on the + // tag-based backends (no library comment/tag on the seeded rule) and for a + // foreign nft table, true on firewalld, whose container is the zone itself, so + // every rule read from it carries the flag. + hasPrefix bool + // inScope reports whether the backend's mutations reach the seeded rule. + // Everything but a foreign nft table is in scope; nft reports foreign tables + // but scopes its writes to its own, so RemoveRule must no-op there without + // error (Sync relies on exactly that). + inScope bool + // undo unseeds, best effort — the suite's RemoveRule normally already has. + undo func() +} + +// runIntegration constructs each backend in backends and runs the capability suite +// against it. It honors FIREWALL_BACKEND: when set, only that backend runs and a +// construction failure is fatal (the environment is expected to provide it); when +// unset, backends that fail to construct are skipped. Each platform's +// TestIntegration (in the per-OS integration__test.go files) calls this with +// the backends available there. +func runIntegration(t *testing.T, backends []backendFactory) { + // Trim whitespace: a value passed through a Windows `set VAR=x && ...` picks up + // a trailing space, and a shell may add a stray CR. + want := strings.TrimSpace(os.Getenv("FIREWALL_BACKEND")) + + ran := 0 + for _, b := range backends { + if want != "" && b.name != want { + continue + } + t.Run(b.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + mgr, err := b.new(ctx, integrationPrefix) + if err != nil { + if want != "" { + t.Fatalf("construct %s backend: %v", b.name, err) + } + t.Skipf("%s backend not available: %v", b.name, err) + } + // reconstruct builds a fresh manager of the same backend, simulating a + // process restart against firewall state that outlives the process. Some + // invariants (a set default policy surviving a later mutation) only hold + // across a fresh instance, so the suite needs to build one on demand. + reconstruct := func(ctx context.Context) (Manager, error) { + return b.new(ctx, integrationPrefix) + } + runManagerSuite(t, mgr, reconstruct) + }) + ran++ + } + + if want != "" && ran == 0 { + t.Fatalf("FIREWALL_BACKEND=%q does not name a known backend", want) + } +} + +// runManagerSuite runs the capability-driven feature suite against a constructed +// manager. Each feature is a subtest gated on the backend's advertised +// Capabilities(); unsupported features are skipped rather than exercised. +// reconstruct builds a fresh manager of the same backend for invariants that only +// hold across a simulated process restart. +func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context) (Manager, error)) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + caps := mgr.Capabilities() + t.Logf("backend %s output=%v capabilities=%+v", mgr.Type(), caps.Output, caps) + + // Resolve the zone once. firewalld returns its default zone; the others + // return "" and ignore the argument. + zone, err := mgr.GetZone(ctx, "") + if err != nil { + zone = "" + } + + defer func() { + // Best-effort: activate whatever the suite left behind, then close. Both are + // part of the Manager contract and worth exercising once per backend. + _ = mgr.Reload(ctx) + _ = mgr.Close(ctx) + }() + + // --- filter-rule features ------------------------------------------------- + + t.Run("basic", func(t *testing.T) { + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Action: Accept}) + }) + + t.Run("protoonly", func(t *testing.T) { + // A bare protocol match with no port and no address ("allow all TCP inbound"). + // ufw must carry the protocol rather than emit a bare `allow in`, which ufw + // rejects. Backends that cannot express a portless, address-less protocol match + // skip via the ErrUnsupported sentinel. + rule := &Rule{Proto: TCP, Action: Accept} + err := mgr.AddRule(ctx, zone, rule) + if errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express a portless, address-less protocol match") + } + require.NoError(t, err) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) + + rules := rulesOf(t, ctx, mgr, zone) + require.True(t, containsRule(rules, rule, mgr.Capabilities().Output), + "bare TCP rule not found in %s", dumpRules(rules)) + require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output), + "bare TCP rule still present after removal") + }) + + t.Run("hasprefix", func(t *testing.T) { + // A rule this library adds must read back exactly once — nft and pf also + // list foreign tables/anchors and must not re-list their own — and report + // HasPrefix: the suite runs with a non-empty prefix, so every backend either + // tags its rules with that prefix or isolates them in its own container. + // + // The rule carries a source address on purpose. An address-less port accept + // lands in apf/csf's native shared port lists (conf.apf IG_TCP_CPORTS, + // csf.conf TCP_IN) — a comma-separated value on a single config line with + // nowhere to attach a per-rule prefix, so HasPrefix is legitimately false + // there (documented in apf_linux.go/csf_linux.go). A host+port accept instead + // routes those backends into their taggable allow files, exercising the + // HasPrefix contract on a rule form every backend can tag or isolate. + rule := &Rule{Proto: TCP, Port: 3456, Source: "192.0.2.10/32", Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, rule)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) + + var matches []*Rule + for _, r := range rulesOf(t, ctx, mgr, zone) { + if r.EqualBase(rule, mgr.Capabilities().Output) { + matches = append(matches, r) + } + } + require.Len(t, matches, 1, "an added rule must read back exactly once, got %s", dumpRules(matches)) + require.True(t, matches[0].HasPrefix, "a rule this library added must report HasPrefix") + }) + + t.Run("foreignrule", func(t *testing.T) { + // The library manages the ACTUAL firewall, not only its own rules: a rule + // created out of band with the backend's own tooling must surface in + // GetRules and be removable with RemoveRule, or Sync/Backup/Restore could + // never converge on real firewall state. Each platform provides a seeder + // that plants a rule the way an operator would (foreignSeeder); a backend + // with no seeder skips. + seeder := foreignSeeder(mgr) + if seeder == nil { + t.Skip("no out-of-band seeder for this backend") + } + seed, err := seeder(zone) + if err != nil { + t.Skipf("could not seed a foreign rule: %v", err) + } + t.Cleanup(seed.undo) + + rules := rulesOf(t, ctx, mgr, zone) + require.True(t, containsRule(rules, seed.rule, caps.Output), + "a rule seeded out of band must surface in GetRules, got %s", dumpRules(rules)) + got := findRule(t, ctx, mgr, zone, seed.rule) + require.Equal(t, seed.hasPrefix, got.HasPrefix, + "HasPrefix must reflect how the backend namespaces its rules (informational only, never an ownership filter)") + + if !seed.inScope { + // Reported but outside the backend's mutation scope (a foreign nft + // table): RemoveRule must no-op without error and leave the rule alone, + // or Sync would fail hard on every reconcile that sees it. + require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule), + "removing a reported out-of-scope rule must no-op, not error") + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, caps.Output), + "an out-of-scope foreign rule must survive the no-op removal") + return + } + require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule), + "a foreign rule is managed like any other and must be removable") + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, caps.Output), + "foreign rule still present after removal") + }) + + t.Run("output", func(t *testing.T) { + requireCap(t, caps.Output) + roundTripRule(t, ctx, mgr, zone, &Rule{Direction: DirOutput, Proto: TCP, Port: 8080, Action: Accept}) + }) + + t.Run("forward", func(t *testing.T) { + requireCap(t, caps.Forward) + // A classic routed-traffic filter: allow forwarding TCP from one network to + // another through the host. It binds no interface, so it does not depend on + // the test host having a particular NIC. + roundTripRule(t, ctx, mgr, zone, &Rule{ + Direction: DirForward, Family: IPv4, Proto: TCP, Port: 8080, + Source: "192.0.2.0/24", Destination: "198.51.100.0/24", Action: Accept, + }) + }) + + t.Run("hostaddress", func(t *testing.T) { + // A rule matching a single host address, written with an explicit /32. + // Backends re-spell an address on read — nft and ufw strip the /32, + // firewalld requires and stores a family, iptables-save adds the /32 — so + // this is where a rule silently fails to read back or reconcile. Left + // FamilyAny so the backend must resolve the family from the address itself. + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "192.0.2.10/32", Action: Accept}) + }) + + t.Run("hostnoport", func(t *testing.T) { + // Portless host shapes. csf/apf trust files store a single all-protocol + // address and their advanced rule holds a single port, so a portless + // concrete-protocol host, a source+destination pair, and (for apf) a + // multi-port list have no trust-file form there and route to the + // raw-iptables hook (shapeNeedsHook / needsHook), while csf expresses a + // multi-port list natively as a comma list. The chain backends express the + // same shapes natively. Every backend runs every shape so a bug in + // whichever path expresses it shows up; a backend that cannot express a + // shape at all skips via the ErrUnsupported sentinel. + t.Run("protohost", func(t *testing.T) { + // A concrete-protocol host with no port. + roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Proto: TCP, Source: "192.0.2.40/32", Action: Accept}) + }) + t.Run("srcdstpair", func(t *testing.T) { + // A source+destination pair with no port. + roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Source: "192.0.2.41/32", Destination: "192.0.2.42/32", Action: Accept}) + }) + t.Run("multiportdeny", func(t *testing.T) { + // A multi-port list as a deny: apf writes it to the hook with its literal + // action, while csf writes it to csf.deny, whose action follows csf.conf + // (stock DROP). Probe both actions and round-trip the first the backend + // accepts, mirroring the denyaddress case. firewalld/pf fan the list + // into one row per port and ufw coalesces adjacent ports into a range, + // so the read-back is checked by coverage. + var rule *Rule + for _, v := range []*Rule{ + {Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Reject}, + {Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Drop}, + } { + err := mgr.AddRule(ctx, zone, v) + if errors.Is(err, ErrUnsupported) { + continue + } + require.NoError(t, err) + require.NoError(t, mgr.RemoveRule(ctx, zone, v)) + rule = v + break + } + if rule == nil { + t.Skip("backend cannot express this shape in any probed form") + } + roundTripPortSet(t, ctx, mgr, zone, rule) + }) + t.Run("multiporthost", func(t *testing.T) { + // A multi-port list as a host accept: apf hooks it, csf writes an + // advanced rule, firewalld/pf fan it into one row per port, and ufw + // coalesces adjacent discrete ports into a range; coverage checks the + // read-back in every form. + roundTripPortSet(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 6001, End: 6001}, {Start: 6002, End: 6002}}, Source: "192.0.2.43/32", Action: Accept}) + }) + }) + + t.Run("matchcombos", func(t *testing.T) { + // Combined matches that overflow a csf/apf advanced line — it holds exactly + // one address field and one port-flow field — so both route them to their + // raw-iptables hook (shapeNeedsHook); the chain backends match -s with -d + // and --sport with --dport directly. Every backend runs every combo; the + // only capability in play is PortPair (firewalld's rich rules carry a + // single port element). Port 22 is avoided in every field so an SSH-driven + // run keeps its session. + t.Run("portedpair", func(t *testing.T) { + // A source+destination pair carrying a destination port: the second address + // overflows the advanced line on csf/apf, reaching MarshalAdvRule. Every + // backend expresses it; no capability gates it, so the round trip is + // unconditional. + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8443, Source: "192.0.2.50/32", Destination: "198.51.100.50/32", Action: Accept}) + }) + t.Run("sourceportpair", func(t *testing.T) { + // The same pair on a source port. + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: UDP, SourcePort: 5353, Source: "192.0.2.51/32", Destination: "198.51.100.51/32", Action: Accept}) + }) + t.Run("icmppair", func(t *testing.T) { + // The same pair on a typed ICMP match. + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.52/32", Destination: "198.51.100.52/32", Action: Accept}) + }) + t.Run("bothports", func(t *testing.T) { + // A source port matched together with a destination port, with an + // address. firewalld advertises PortPair false (a rich rule carries a + // single port element) and is gated out; everyone else must express it. + requireCap(t, caps.PortPair) + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8444, SourcePort: 5354, Source: "192.0.2.53/32", Action: Accept}) + }) + t.Run("bothportsbare", func(t *testing.T) { + // The same source+destination port match without an address. + requireCap(t, caps.PortPair) + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8445, SourcePort: 5355, Action: Accept}) + }) + }) + + t.Run("negatedaddress", func(t *testing.T) { + requireCap(t, caps.Negation) + // A negated source must round-trip through whatever path expresses it: + // iptables/nft/pf/firewalld negate natively, ufw's tuple grammar cannot so + // it reroutes to the before.rules raw path (iptables `! -s`), and csf/apf + // route it to their raw-iptables hook. wf advertises Negation false (WFP + // has no negated address condition) and is gated out; everyone else must + // express it. + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "!192.0.2.50/32", Action: Accept}) + }) + + t.Run("denyaddress", func(t *testing.T) { + // A deny rule carrying a host address exercises the deny-list address path + // (csf.deny / apf deny_hosts, and the reject chains elsewhere). Reject is + // used because the address-list backends canonicalize a deny to it; Windows + // Filtering Platform has no reject action and falls back to Drop. + roundTripVariants(t, ctx, mgr, zone, + &Rule{Family: IPv4, Proto: TCP, Port: 3389, Source: "192.0.2.20/32", Action: Reject}, + &Rule{Family: IPv4, Proto: TCP, Port: 3389, Source: "192.0.2.20/32", Action: Drop}, + ) + }) + + t.Run("denyexactaction", func(t *testing.T) { + // A deny must read back with the exact action it was added with, or a + // caller managing it sees churn on every Sync (read back as one action, + // never equal to the desired other). The stakes are highest on csf/apf, + // whose deny stores encode no action of their own — the tool applies an + // action taken from config (csf.conf DROP / conf.apf ALL_STOP, stock + // default DROP), so a deny whose action matches config is stored natively + // and one that differs is injected through their pre-hook rather than + // refused — but the property holds for every backend, so both actions run + // everywhere. A backend with no reject action at all (wf) advertises + // RejectAction false and skips that action by capability. + for _, tc := range []struct { + name string + deny *Rule + }{ + {"drop", &Rule{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.30/32", Action: Drop}}, + {"reject", &Rule{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.31/32", Action: Reject}}, + } { + deny := tc.deny + t.Run(tc.name, func(t *testing.T) { + if deny.Action == Reject { + requireCap(t, caps.RejectAction) + } + require.NoError(t, mgr.AddRule(ctx, zone, deny)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, deny) }) + + // The rule must read back with its exact action so it compares equal to the + // desired rule and Sync leaves it in place rather than churning it. + got := findRule(t, ctx, mgr, zone, deny) + require.Equal(t, deny.Action, got.Action, + "a deny must read back with its exact action, not a config default: %+v", deny) + require.True(t, got.Equal(deny, mgr.Capabilities().Output), + "the read-back deny must equal the desired rule so Sync does not churn it: %+v", deny) + + require.NoError(t, mgr.RemoveRule(ctx, zone, deny)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), deny, mgr.Capabilities().Output), + "deny rule still present after removal: %+v", deny) + }) + } + }) + + t.Run("removeclearscustomerrawcopy", func(t *testing.T) { + // A backend that keeps a raw-iptables side store alongside its native store — + // the csf/apf pre-hook, ufw's before.rules — can hold a hand-added copy of a + // rule it could just as well express natively: a customer editing csfpre.sh / + // hook_pre.sh / before.rules. RemoveRule must clear that copy too: routing such + // a rule only to the native path would remove it from the config while leaving + // it running in the raw store. Plant the rule directly in the store to stand in + // for the hand-added copy; a backend with no side store skips. + plant := rawCopyPlanter(mgr) + if plant == nil { + t.Skip("backend has no raw side store") + } + + // A host+port accept is a natively expressible shape, so RemoveRule routes + // it to the native store and must look in the raw store too. + r := &Rule{Family: IPv4, Proto: TCP, Port: 4567, Source: "192.0.2.60/32", Action: Accept} + require.NoError(t, plant(r), "planting the customer raw copy must succeed") + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), + "the planted raw rule must read back") + + require.NoError(t, mgr.RemoveRule(ctx, zone, r)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), + "RemoveRule must clear a native-shaped rule's raw-store copy, not leave it running") + }) + + t.Run("denyport", func(t *testing.T) { + // A port-only deny (a port with no address) exercises the address-less deny + // path. Native backends write it directly; the csf/apf address-list backends + // have no way to carry a bare port, so they must synthesize the "any" network + // (0.0.0.0/0 // ::/0) as a placeholder address — otherwise the line is parsed + // but silently never applied by the tool, leaving the port open while AddRule + // reports success. The deny action follows config on those backends (csf.conf + // DROP / apf ALL_STOP, stock default Drop), so try Reject first (native reject + // backends) then Drop (the address-list default); roundTripVariants asserts the + // accepted form is actually present after add, which fails if nothing was written. + roundTripVariants(t, ctx, mgr, zone, + &Rule{Proto: TCP, Port: 3391, Action: Reject}, + &Rule{Proto: TCP, Port: 3391, Action: Drop}, + ) + }) + + t.Run("familypairremove", func(t *testing.T) { + // A v4 rule and its v6 twin are two rows on most backends. Removing every rule + // the backend reports must clear them all. The remove must match a + // family-agnostic rule (not no-op and leave the port open) and must clear both + // rows, not just one. Gated on FamilyWithoutAddress (the probe rules are + // family-pinned bare ports); the per-family sentinel-continue below covers + // only the environment case + // of a backend whose own IPv6 handling is off (csf/apf). + requireCap(t, caps.FamilyWithoutAddress) + const port = 3492 + v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept} + v6 := &Rule{Family: IPv6, Proto: TCP, Port: port, Action: Accept} + added := 0 + for _, r := range []*Rule{v4, v6} { + err := mgr.AddRule(ctx, zone, r) + if errors.Is(err, ErrUnsupported) { + continue // the backend cannot express this family of a bare-port accept. + } + require.NoError(t, err) + added++ + } + if added == 0 { + t.Skip("backend expresses neither family of a bare-port accept") + } + t.Cleanup(func() { + _ = mgr.RemoveRule(ctx, zone, v4) + _ = mgr.RemoveRule(ctx, zone, v6) + }) + + portRule := func(r *Rule) bool { return r.Proto == TCP && r.Port == port } + + // With both families expressible, GetRules must report coverage for BOTH. A + // cross-family dedup that matches on a family-agnostic Equal would drop the + // v6 add and leave IPv6 unprotected; a concrete-family removal must then clear + // only its own family and leave the twin in place. + if added == 2 { + familyCoverage := func() (v4Cov, v6Cov bool) { + for _, r := range rulesOf(t, ctx, mgr, zone) { + if !portRule(r) { + continue + } + switch r.Family { + case FamilyAny: + v4Cov, v6Cov = true, true + case IPv4: + v4Cov = true + case IPv6: + v6Cov = true + } + } + return + } + has4, has6 := familyCoverage() + require.True(t, has4 && has6, + "both families must be present after adding the v4/v6 pair; a missing family means a cross-family add was silently dropped") + + require.NoError(t, mgr.RemoveRule(ctx, zone, v4)) + has4, has6 = familyCoverage() + require.True(t, has6, "removing the v4 twin must leave the v6 twin in place") + require.False(t, has4, "removing the v4 twin must not leave a v4 row behind") + + // Restore the pair so the remove-all phase below starts from both rows. + require.NoError(t, mgr.AddRule(ctx, zone, v4)) + } + + // Remove each rule the backend reports for this port (one family-agnostic rule, + // or one per family), then confirm none remain — a removal must not leave a + // twin row behind. + for _, r := range rulesOf(t, ctx, mgr, zone) { + if portRule(r) { + require.NoError(t, mgr.RemoveRule(ctx, zone, r)) + } + } + for _, r := range rulesOf(t, ctx, mgr, zone) { + require.False(t, portRule(r), "port %d rule still present after removal: %+v", port, r) + } + }) + + t.Run("familyanysplitremove", func(t *testing.T) { + // Unlike familypairremove (which adds a v4/v6 PAIR), this adds ONE FamilyAny + // rule. A FamilyAny rule with no address is stored as a single dual-family + // object by the unified backends (nft inet, pf without af, firewalld's + // dual-stack zone entries) and as one row per family by the separated backends; + // either way GetRules reports coverage for both families. Removing a single + // family must leave the other in place — the backend splits the dual object + // (apf splits its dual-stack port list through the raw-iptables hook). The + // split is gated on FamilyWithoutAddress: wf's address-less filters cannot + // pin one family, so it advertises the capability false. A concrete-family + // removal must drop only the targeted family (nft/pf must not over-remove + // both; firewalld zone entries must not under-remove and leave both). + + // splitCase is one dual-family rule shape plus a matcher over GetRules output. + type splitCase struct { + anyRule, v4, v6 *Rule + match func(*Rule) bool + } + + // runSplit exercises a shape: add the FamilyAny rule, remove each family in + // turn, and confirm the other survives. A single-family removal implies + // expressing a single-family rule of the shape, so the split is gated on + // FamilyWithoutAddress (wf cannot pin a family without an address). + runSplit := func(t *testing.T, c splitCase) { + requireCap(t, caps.FamilyWithoutAddress) + coverage := func() (v4Cov, v6Cov bool) { + for _, r := range rulesOf(t, ctx, mgr, zone) { + if !c.match(r) { + continue + } + switch r.Family { + case FamilyAny: + v4Cov, v6Cov = true, true + case IPv4: + v4Cov = true + case IPv6: + v6Cov = true + } + } + return + } + clear := func() { + for _, r := range rulesOf(t, ctx, mgr, zone) { + if c.match(r) { + require.NoError(t, mgr.RemoveRule(ctx, zone, r)) + } + } + } + + // The backend must express this FamilyAny shape with dual coverage; skip + // where it cannot (csf/apf need an address, etc.). + if err := mgr.AddRule(ctx, zone, c.anyRule); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express this bare FamilyAny shape") + } else { + require.NoError(t, err) + } + t.Cleanup(clear) + if has4, has6 := coverage(); !(has4 && has6) { + clear() + t.Skipf("backend does not give this FamilyAny shape dual coverage (v4=%v v6=%v)", has4, has6) + } + + // Remove IPv4; IPv6 must survive. FamilyWithoutAddress is required + // above, so a single-family removal of the shape must be expressible. + require.NoError(t, mgr.RemoveRule(ctx, zone, c.v4)) + has4, has6 := coverage() + require.False(t, has4, "removing IPv4 must clear IPv4 coverage") + require.True(t, has6, "removing IPv4 from a FamilyAny rule must leave IPv6 in place") + + // Opposite direction from a clean slate: remove IPv6, IPv4 must survive. + clear() + require.NoError(t, mgr.AddRule(ctx, zone, c.anyRule)) + if has4, has6 := coverage(); !(has4 && has6) { + t.Fatalf("re-adding the FamilyAny rule must restore both families (v4=%v v6=%v)", has4, has6) + } + require.NoError(t, mgr.RemoveRule(ctx, zone, c.v6)) + has4, has6 = coverage() + require.False(t, has6, "removing IPv6 must clear IPv6 coverage") + require.True(t, has4, "removing IPv6 from a FamilyAny rule must leave IPv4 in place") + } + + t.Run("destport", func(t *testing.T) { + const p uint16 = 3493 + runSplit(t, splitCase{ + anyRule: &Rule{Family: FamilyAny, Proto: TCP, Port: p, Action: Accept}, + v4: &Rule{Family: IPv4, Proto: TCP, Port: p, Action: Accept}, + v6: &Rule{Family: IPv6, Proto: TCP, Port: p, Action: Accept}, + match: func(r *Rule) bool { + s := r.PortSpecs() + return r.Proto == TCP && len(s) == 1 && s[0].Start == p && !r.HasSourcePorts() + }, + }) + }) + + t.Run("sourceport", func(t *testing.T) { + const p uint16 = 3494 + runSplit(t, splitCase{ + anyRule: &Rule{Family: FamilyAny, Proto: TCP, SourcePort: p, Action: Accept}, + v4: &Rule{Family: IPv4, Proto: TCP, SourcePort: p, Action: Accept}, + v6: &Rule{Family: IPv6, Proto: TCP, SourcePort: p, Action: Accept}, + match: func(r *Rule) bool { + s := r.SourcePortSpecs() + return r.Proto == TCP && len(s) == 1 && s[0].Start == p && !r.HasPorts() + }, + }) + }) + + t.Run("ordering", func(t *testing.T) { + requireCap(t, caps.RuleOrdering) + // On an ordered backend the surviving family must keep the dual rule's place + // in the chain, not jump to the end after the split re-adds it. AddRule order + // is backend-specific (nft appends, iptables prepends), so read the actual + // order rather than assume it, then assert the split leaves it unchanged. The + // split removes IPv6 and every probe rule's survivor is IPv4, so a + // family-separated backend (iptables/ufw read their v4 chain first) keeps the + // survivor in its slot too — no false failure there. + const before, dual, after uint16 = 3496, 3497, 3498 + rBefore := &Rule{Family: IPv4, Proto: TCP, Port: before, Action: Accept} + rDual := &Rule{Family: FamilyAny, Proto: TCP, Port: dual, Action: Accept} + rAfter := &Rule{Family: IPv4, Proto: TCP, Port: after, Action: Accept} + for _, r := range []*Rule{rBefore, rDual, rAfter} { + if err := mgr.AddRule(ctx, zone, r); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express one of the ordering probe rules") + } else { + require.NoError(t, err) + } + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) }) + } + // A family-separated backend stores the dual rule as a v4 row and a v6 row, so + // read the order of the rows that survive the split — everything but IPv6 — + // and require the split to leave exactly that sequence in place. + ports := map[uint16]bool{before: true, dual: true, after: true} + survivingOrder := func() []uint16 { + var out []uint16 + for _, r := range rulesOf(t, ctx, mgr, zone) { + if ports[r.Port] && r.impliedFamily() != IPv6 { + out = append(out, r.Port) + } + } + return out + } + order0 := survivingOrder() + require.Len(t, order0, 3, "all three probe rules should be present before the split") + + // Split off IPv6; the surviving IPv4 row must keep the dual rule's position. + require.NoError(t, mgr.RemoveRule(ctx, zone, &Rule{Family: IPv6, Proto: TCP, Port: dual, Action: Accept})) + require.Equal(t, order0, survivingOrder(), + "the re-added surviving family must keep the dual rule's position") + }) + }) + + t.Run("tcpudproundtrip", func(t *testing.T) { + // A TCPUDP rule matches both transports. Backends with no both-transports form + // (iptables, pf, firewalld, wf, csf/apf's per-transport config lists) store it + // as a tcp row plus a udp row; nftables stores it as one `meta l4proto + // { tcp, udp }` row; ufw as one any-protocol tuple. GetRules reports whichever + // rows the backend actually holds, so the read-back is checked by coverage: the + // rows must cover the rule and none may widen it. Re-adding must not duplicate, + // and one remove must clear every transport. + const p uint16 = 3494 + rule := &Rule{Proto: TCPUDP, Port: p, Action: Accept} + match := func(r *Rule) bool { return r.Port == p && onProtocolAxis(r.Proto) } + + if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express a both-transports port rule") + } else { + require.NoError(t, err) + } + t.Cleanup(func() { + for _, r := range rulesOf(t, ctx, mgr, zone) { + if match(r) { + _ = mgr.RemoveRule(ctx, zone, r) + } + } + }) + + found := func() []*Rule { + var out []*Rule + for _, r := range rulesOf(t, ctx, mgr, zone) { + if match(r) { + out = append(out, r) + } + } + return out + } + + got := found() + require.NotEmpty(t, got, "the rule must read back") + require.True(t, rule.CoveredBy(got), "the stored rows must cover both transports, got %+v", got) + for _, r := range got { + require.True(t, rule.Covers(r), "a stored row must not widen the rule: %+v", r) + } + + // Re-adding the same rule is a no-op: every row it fans into dedups against + // what is already stored. + require.NoError(t, mgr.AddRule(ctx, zone, rule)) + require.Len(t, found(), len(got), "re-adding the rule must not duplicate its rows") + + // One remove clears both transports. + require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) + require.Empty(t, found(), "removing the rule must clear both transports") + }) + + t.Run("tcpudpsplitremove", func(t *testing.T) { + // The protocol analog of familyanysplitremove: removing ONE transport of a + // both-transports rule must leave the other in place. Backends that store the + // pair as two rows drop only the targeted row; nftables splits its single + // `meta l4proto { tcp, udp }` row and re-adds the survivor. + const p uint16 = 3495 + both := &Rule{Proto: TCPUDP, Port: p, Action: Accept} + tcp := &Rule{Proto: TCP, Port: p, Action: Accept} + udp := &Rule{Proto: UDP, Port: p, Action: Accept} + match := func(r *Rule) bool { return r.Port == p && onProtocolAxis(r.Proto) } + + coverage := func() (tcpCov, udpCov bool) { + for _, r := range rulesOf(t, ctx, mgr, zone) { + if !match(r) { + continue + } + switch r.Proto { + case TCPUDP: + tcpCov, udpCov = true, true + case TCP: + tcpCov = true + case UDP: + udpCov = true + } + } + return + } + clear := func() { + for _, r := range rulesOf(t, ctx, mgr, zone) { + if match(r) { + _ = mgr.RemoveRule(ctx, zone, r) + } + } + } + + if err := mgr.AddRule(ctx, zone, both); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express a both-transports port rule") + } else { + require.NoError(t, err) + } + t.Cleanup(clear) + if hasT, hasU := coverage(); !(hasT && hasU) { + clear() + t.Skipf("backend does not give this rule both-transport coverage (tcp=%v udp=%v)", hasT, hasU) + } + + // Remove TCP; UDP must survive. + if err := mgr.RemoveRule(ctx, zone, tcp); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express a single-transport removal of this rule") + } else { + require.NoError(t, err) + } + hasT, hasU := coverage() + require.False(t, hasT, "removing tcp must clear tcp coverage") + require.True(t, hasU, "removing tcp from a tcpudp rule must leave udp in place") + + // Opposite direction from a clean slate: remove UDP, TCP must survive. + clear() + require.NoError(t, mgr.AddRule(ctx, zone, both)) + if hasT, hasU := coverage(); !(hasT && hasU) { + t.Fatalf("re-adding the tcpudp rule must restore both transports (tcp=%v udp=%v)", hasT, hasU) + } + require.NoError(t, mgr.RemoveRule(ctx, zone, udp)) + hasT, hasU = coverage() + require.False(t, hasU, "removing udp must clear udp coverage") + require.True(t, hasT, "removing udp from a tcpudp rule must leave tcp in place") + }) + + t.Run("diranysplitremove", func(t *testing.T) { + // A DirAny rule applies in BOTH directions. On the chain backends it is stored + // as an inbound rule plus a role-swapped outbound rule (two physical rows); on + // csf/apf a bare host is one bidirectional plain csf.allow/allow_hosts line and + // any ported shape fans out into two hook rules. Either way GetRules collapses + // it back to one DirAny rule. Removing a single direction must leave the other + // in place — the chain backends drop only that direction's row, while csf/apf + // either split their plain line and re-express the survivor through the + // raw-iptables hook (bare host) or drop just that direction's hook rule + // (ported). Backends with no output concept (firewalld) reject the DirAny add + // with ErrUnsupported and skip. This is the direction analog of + // familyanysplitremove: removing the input half of a DirAny rule must never + // drop the output half, mirroring a concrete-family removal leaving the other + // family in place. + requireCap(t, caps.Output) + + // splitCase is one dual-direction rule shape, the concrete-direction removal + // targets (inbound framed as a source match, outbound as the role-swapped + // destination match), and a matcher reporting which directions a read-back rule + // covers for the shape. + type splitCase struct { + anyRule, inTarget, outTarget *Rule + cover func(*Rule) (inCov, outCov bool) + } + + // runSplit exercises a shape: add the DirAny rule, remove each direction in + // turn, and confirm the other survives (or the backend rejects the removal). + runSplit := func(t *testing.T, c splitCase) { + coverage := func() (inCov, outCov bool) { + for _, r := range rulesOf(t, ctx, mgr, zone) { + in, out := c.cover(r) + inCov = inCov || in + outCov = outCov || out + } + return + } + clear := func() { + _ = mgr.RemoveRule(ctx, zone, c.anyRule) + _ = mgr.RemoveRule(ctx, zone, c.inTarget) + _ = mgr.RemoveRule(ctx, zone, c.outTarget) + } + + // The backend must express this bidirectional shape; skip where it cannot. + if err := mgr.AddRule(ctx, zone, c.anyRule); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express this DirAny shape") + } else { + require.NoError(t, err) + } + t.Cleanup(clear) + if inCov, outCov := coverage(); !(inCov && outCov) { + clear() + t.Skipf("backend does not give this DirAny shape dual-direction coverage (in=%v out=%v)", inCov, outCov) + } + + // Remove the input direction; output must survive. + if err := mgr.RemoveRule(ctx, zone, c.inTarget); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express a single-direction removal of this DirAny shape") + } else { + require.NoError(t, err) + } + inCov, outCov := coverage() + require.False(t, inCov, "removing the input direction must clear input coverage") + require.True(t, outCov, "removing the input direction from a DirAny rule must leave output in place") + + // Opposite direction from a clean slate: remove output, input must survive. + clear() + require.NoError(t, mgr.AddRule(ctx, zone, c.anyRule)) + if inCov, outCov := coverage(); !(inCov && outCov) { + t.Fatalf("re-adding the DirAny rule must restore both directions (in=%v out=%v)", inCov, outCov) + } + require.NoError(t, mgr.RemoveRule(ctx, zone, c.outTarget)) + inCov, outCov = coverage() + require.False(t, outCov, "removing the output direction must clear output coverage") + require.True(t, inCov, "removing the output direction from a DirAny rule must leave input in place") + } + + t.Run("barehost", func(t *testing.T) { + // A bare host allow: on csf/apf this is the single-plain-line shape whose + // single-direction removal splits the line and re-adds the survivor via the + // hook (splitDualRowDirection). + const host = "192.0.2.77" + runSplit(t, splitCase{ + anyRule: &Rule{Direction: DirAny, Source: host, Action: Accept}, + inTarget: &Rule{Direction: DirInput, Source: host, Action: Accept}, + outTarget: &Rule{Direction: DirOutput, Destination: host, Action: Accept}, + cover: func(r *Rule) (inCov, outCov bool) { + switch r.Direction { + case DirAny: + if addrEqual(r.Source, host) { + return true, true + } + case DirInput: + if addrEqual(r.Source, host) { + return true, false + } + case DirOutput: + if addrEqual(r.Destination, host) { + return false, true + } + } + return false, false + }, + }) + }) + + t.Run("portedhost", func(t *testing.T) { + // A ported DirAny rule is NOT a bare-host plain line: it fans out into an + // inbound dport row and its role-swapped outbound sport twin — two physical + // rows on the chain backends, two hook rules on csf/apf. The two rows share + // an identical inbound-frame match, so only the direction guard in + // EqualForRemoval keeps a single-direction removal from taking the twin as + // well. This is the direction analog of the family split's destport/ + // sourceport cases. + const host = "192.0.2.81" + const p uint16 = 3499 + runSplit(t, splitCase{ + anyRule: &Rule{Direction: DirAny, Proto: TCP, Port: p, Source: host, Action: Accept}, + inTarget: &Rule{Direction: DirInput, Proto: TCP, Port: p, Source: host, Action: Accept}, + outTarget: &Rule{Direction: DirOutput, Proto: TCP, SourcePort: p, Destination: host, Action: Accept}, + cover: func(r *Rule) (inCov, outCov bool) { + if r.Proto != TCP { + return false, false + } + soleDest := func() bool { + s := r.PortSpecs() + return len(s) == 1 && s[0].Start == p && !r.HasSourcePorts() + } + soleSource := func() bool { + s := r.SourcePortSpecs() + return len(s) == 1 && s[0].Start == p && !r.HasPorts() + } + switch r.Direction { + case DirAny: + // A bidirectional row, stated in the inbound frame: dport p from + // the host. + if addrEqual(r.Source, host) && soleDest() { + return true, true + } + case DirInput: + if addrEqual(r.Source, host) && soleDest() { + return true, false + } + case DirOutput: + // The surviving outbound twin: sport p to the host. + if addrEqual(r.Destination, host) && soleSource() { + return false, true + } + } + return false, false + }, + }) + }) + }) + + t.Run("diranyroundtrip", func(t *testing.T) { + // A DirAny rule reads back as whatever the backend stores: one bidirectional + // line where its config has that form (csf.allow, apf's allow_hosts), otherwise + // an inbound row plus its role-swapped outbound row. Either way the rows must + // cover the rule and none may widen it, and a second add must be an idempotent + // no-op rather than doubling the rows. + requireCap(t, caps.Output) + rule := &Rule{Direction: DirAny, Source: "192.0.2.78", Action: Accept} + if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express a bidirectional bare host allow") + } else { + require.NoError(t, err) + } + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) + + matches := func() []*Rule { + var out []*Rule + for _, r := range rulesOf(t, ctx, mgr, zone) { + if addrEqual(r.Source, "192.0.2.78") || addrEqual(r.Destination, "192.0.2.78") { + out = append(out, r) + } + } + return out + } + got := matches() + require.True(t, rule.CoveredBy(got), "the stored rows must cover both directions, got %+v", got) + for _, r := range got { + require.True(t, rule.Covers(r), "a stored row must not widen the rule: %+v", r) + } + + // A redundant add must not create a second copy of any row. + require.NoError(t, mgr.AddRule(ctx, zone, rule)) + require.Len(t, matches(), len(got), "re-adding an existing DirAny rule must be a no-op") + }) + + t.Run("diranyported", func(t *testing.T) { + // A DirAny rule that is NOT a bare host — here a host + destination port — must + // still round-trip: it fans out into an inbound (dport) rule and its role- + // swapped outbound (sport) twin, which together cover the rule on read. This + // exercises the fan-out path that a plain csf.allow/apf line (bare host) does + // not use. Skip where the backend cannot express the shape. + requireCap(t, caps.Output) + rule := &Rule{Direction: DirAny, Proto: TCP, Port: 22, Source: "192.0.2.80", Action: Accept} + if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express this DirAny ported host rule") + } else { + require.NoError(t, err) + } + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) + require.True(t, rule.CoveredBy(rulesOf(t, ctx, mgr, zone)), + "the stored rows must cover both directions of the ported host rule") + + require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) + require.False(t, rule.CoveredBy(rulesOf(t, ctx, mgr, zone)), + "DirAny ported host rule still present after removal") + }) + + t.Run("diranynooutputfallback", func(t *testing.T) { + // On a backend with no output concept (firewalld), a DirAny rule cannot be a + // both-directions rule, so it must degrade to its input half rather than error: + // the add succeeds and reads back as an input rule, and the same DirAny target + // removes it. + if caps.Output { + t.Skip("backend distinguishes output; DirAny fans out instead of degrading") + } + const host = "192.0.2.79" + rule := &Rule{Direction: DirAny, Source: host, Action: Accept} + if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express this host allow at all") + } else { + require.NoError(t, err, "a DirAny rule must degrade to input, not error, on a no-output backend") + } + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) + + found := false + for _, r := range rulesOf(t, ctx, mgr, zone) { + if r.Direction == DirInput && addrEqual(r.Source, host) { + found = true + } + } + require.True(t, found, "the degraded DirAny rule must read back as an input rule") + + // Removal by the same DirAny target must clear it. + require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) + for _, r := range rulesOf(t, ctx, mgr, zone) { + require.Falsef(t, addrEqual(r.Source, host), "degraded DirAny rule still present: %+v", r) + } + }) + + t.Run("icmp", func(t *testing.T) { + // ICMP has genuinely different shapes per backend: nft/iptables/ufw accept a + // bare rule; apf models ICMP as a list of allowed types, so a rule with an + // address or a non-accept action goes to its hook; csf builds native ICMP on + // host-based advanced rules needing an address AND a type (the advanced-rule + // format carries the type in the single port-flow field), so every other shape + // goes to its hook. Both hook forms are plain iptables rules, so the bare + // variant is what csf and apf match here. Offer all forms and use the first the + // backend accepts. + roundTripVariants(t, ctx, mgr, zone, + &Rule{Family: IPv4, Proto: ICMP, Action: Accept}, + &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, + &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.0/24", Action: Accept}, + ) + }) + + t.Run("icmpv6", func(t *testing.T) { + requireCap(t, caps.IPv6) + roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}) + }) + + t.Run("reload", func(t *testing.T) { + // Every backend must survive an actual reload/apply with a managed rule in + // place: csf must ride out its restart lock, apf must be able to run + // `apf --restart`. The v6 variant below covers the backends that keep IPv6 + // rules in a separate file, but it is gated on IPv6 support — which csf and + // apf drop when their own IPv6 handling is off — so exercise Reload here for + // everyone with a plain IPv4-expressible rule. + r := &Rule{Proto: TCP, Port: 3530, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, r)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) }) + require.NoError(t, mgr.Reload(ctx), "reload must succeed with a managed rule present") + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), "rule missing after reload") + require.NoError(t, mgr.RemoveRule(ctx, zone, r)) + require.NoError(t, mgr.Reload(ctx), "reload must succeed after removing the rule") + }) + + t.Run("reloadv6raw", func(t *testing.T) { + requireCap(t, caps.IPv6) + // An IPv6 rule a backend keeps in its own IPv6 rule file must survive an + // actual reload/apply. ufw re-applies before6.rules through ip6tables-restore + // on `ufw reload`, so the file must reference the correct `ufw6-` chain names; + // apf must be able to run `apf --restart`; csf must ride out its restart lock. + r := &Rule{Family: IPv6, Proto: ICMPv6, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, r)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) }) + require.NoError(t, mgr.Reload(ctx), "reload must succeed with an IPv6 raw rule present") + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), "rule missing after reload") + require.NoError(t, mgr.RemoveRule(ctx, zone, r)) + require.NoError(t, mgr.Reload(ctx), "reload must succeed after removing the IPv6 raw rule") + }) + + t.Run("icmptype", func(t *testing.T) { + // A specific ICMP type, with an addressed fallback for a backend whose native + // type list cannot carry the bare form. Use type 13 (Timestamp) rather than the + // more common echo-request type 8 to avoid colliding with Windows' built-in + // echo-request rules, which cannot be removed and would make the round-trip + // assertions match the wrong rule. + roundTripVariants(t, ctx, mgr, zone, + &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Accept}, + &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.0/24", Action: Accept}, + ) + }) + + t.Run("icmptypedhost", func(t *testing.T) { + // A typed ICMP match carrying a host address. The shared icmp/icmptype + // probes stop at the first form a backend accepts, which hides this one: + // it is csf's only NATIVE ICMP form (a csf.allow advanced line carries + // exactly one address and a concrete type; every other shape routes to its + // hook, see CSF.needsHook), and on apf — whose native ICMP is the address-less + // IG_ICMP_TYPES list — the shape that routes to the hook (needsHook). Every + // backend runs it so both paths, and everyone else's addressed ICMP match, + // stay covered. Type 13 (Timestamp) avoids colliding with Windows' + // unremovable built-in echo-request rules. + roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.70/32", Action: Accept}) + }) + + t.Run("icmptypeddrop", func(t *testing.T) { + // A typed ICMP drop with no address. A non-accept action has no place in + // apf's allowed-type list, so it routes to apf's hook; the other backends + // express it directly or skip via the ErrUnsupported sentinel. + roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Drop}) + }) + + t.Run("singlefamilyport", func(t *testing.T) { + requireCap(t, caps.FamilyWithoutAddress) + // A single-family bare port accept. Most backends store it directly; apf's + // CPORTS port lists are dual-stack, so it has no native form there and is + // written per-family through the hook (dualStackPortNeedsHook) rather than + // rejected. wf advertises FamilyWithoutAddress false (a WFP filter scopes + // family through its address conditions) and is gated out. The + // FamilyAny→single-family split is exercised by the destport split test. + roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: TCP, Port: 8090, Action: Accept}) + }) + + t.Run("portrange", func(t *testing.T) { + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}) + }) + + t.Run("portlist", func(t *testing.T) { + // Every backend honors a discrete port list: nftables/iptables/ufw/wf and + // the csf/apf hooks store it as one multi-port row (ufw coalescing + // adjacent discrete ports into a range); firewalld and pf fan it into one + // row per port. Uncommon ports keep the match clear of any pre-existing + // port list (csf's default TCP_IN). + roundTripPortSet(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 8480, End: 8480}, {Start: 8443, End: 8443}}, Action: Accept}) + }) + + t.Run("sourceport", func(t *testing.T) { + // Source-port alone where supported (note firewalld rich rules can match a + // source port but not together with a destination port, so the probe never + // combines the two). csf/apf have no address-less advanced-rule form, so they + // route this through the raw-iptables hook; the addressed fallback remains for + // any backend that needs an address on a source-port rule. + roundTripVariants(t, ctx, mgr, zone, + &Rule{Proto: TCP, SourcePort: 1234, Action: Accept}, + &Rule{Proto: TCP, SourcePort: 1234, Source: "192.0.2.0/24", Action: Accept}, + ) + }) + + t.Run("connstate", func(t *testing.T) { + requireCap(t, caps.ConnState) + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, State: StateEstablished | StateRelated, Action: Accept}) + }) + + t.Run("interface", func(t *testing.T) { + requireCap(t, caps.InterfaceMatch) + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, InInterface: "eth0", Action: Accept}) + }) + + t.Run("logging", func(t *testing.T) { + requireCap(t, caps.Logging) + // pf logs but has no text prefix on the `log` keyword, so fall back to a + // prefix-less logged rule there. + roundTripVariants(t, ctx, mgr, zone, + &Rule{Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "it"}, + &Rule{Proto: TCP, Port: 22, Action: Accept, Log: true}, + ) + }) + + t.Run("ratelimit", func(t *testing.T) { + requireCap(t, caps.RateLimit) + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}}) + }) + + t.Run("connlimit", func(t *testing.T) { + requireCap(t, caps.ConnLimit) + // Connection limiting splits across backends: nft expresses only a global + // cap; pf a per-source one only on an accept rule (single inbound tcp port, no + // address); iptables does the global form. csf and apf carry one native shape + // each in their config and route every other shape to their hook, whose + // iptables rule does the global form too. Try each and round-trip the first the + // backend accepts. + roundTripVariants(t, ctx, mgr, zone, + &Rule{Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}}, + &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: true}}, + &Rule{Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20, PerSource: true}}, + &Rule{Proto: TCP, Port: 80, Action: Accept, ConnLimit: &ConnLimit{Count: 20, PerSource: true}}, + ) + }) + + t.Run("connlimitpersource", func(t *testing.T) { + requireCap(t, caps.ConnLimit) + // A per-source connection cap on a single address-less inbound tcp port, + // rejecting the excess. The shared connlimit probe stops at the first form + // a backend accepts, which hides this one wherever a global form matched + // first: on csf and apf it is the single NATIVE connection-limit shape + // (csf.conf CONNLIMIT / conf.apf CLIMIT) while every other shape routes to + // their hook, and nft counts it in a named meter set. Every + // connlimit-capable backend runs it; pf alone skips via the ErrUnsupported + // sentinel (its max-src-conn applies only to a pass rule, so a rejecting + // per-source cap has no pf form). + roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}) + }) + + t.Run("denyremovedanyaction", func(t *testing.T) { + requireCap(t, caps.DenyActionFromConfig) + // On a backend whose native deny store carries no per-entry action (the + // tool applies its config's action — see DenyActionFromConfig), the deny of + // an address is a single entry (csf.deny, apf deny_hosts.rules) and must be + // removable whatever action the caller names. Otherwise RemoveRule reports + // success while the tool keeps enforcing the entry. The stock config action + // is DROP on both, so Drop is the native deny action and Reject the + // differing one. + host := "192.0.2.72/32" + added := &Rule{Family: IPv4, Proto: TCP, Port: 8084, Source: host, Action: Drop} + require.NoError(t, mgr.AddRule(ctx, zone, added)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, added) }) + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), added, mgr.Capabilities().Output), + "the native deny must be present before removal") + + // The same rule, named with the other deny action, must still clear the entry. + differing := *added + differing.Action = Reject + require.NoError(t, mgr.RemoveRule(ctx, zone, &differing)) + for _, r := range rulesOf(t, ctx, mgr, zone) { + require.Falsef(t, addrEqual(r.Source, host) && r.Port == 8084, + "the deny entry must be removed whatever action the target names: %+v", r) + } + }) + + t.Run("comment", func(t *testing.T) { + requireCap(t, caps.Comments) + // Most backends carry a comment on a bare port rule; CSF and APF can only + // attach one to an address-based (IP-list) rule, so fall back to that + // form when the first probe succeeds but drops the comment. + variants := []*Rule{ + {Proto: TCP, Port: 22, Action: Accept, Comment: "it-comment"}, + {Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.0/24", Action: Accept, Comment: "it-comment"}, + } + for _, rule := range variants { + rule := rule + require.NoError(t, mgr.AddRule(ctx, zone, rule)) + got := findRule(t, ctx, mgr, zone, rule) + if got.Comment == rule.Comment { + require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output), "rule still present after removal") + return + } + // Comment did not round-trip on this form; remove and try the next. + require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) + } + t.Fatal("comment did not round-trip on any probe form") + }) + + t.Run("priority", func(t *testing.T) { + requireCap(t, caps.Priority) + // A rule reads back carrying its priority, and priority is part of rule + // identity: an otherwise-identical rule at a different priority is a + // distinct rule, so a reconcile can actually change a rule's priority. + r := &Rule{Family: IPv4, Proto: TCP, Port: 5100, Action: Accept, Priority: 10} + require.NoError(t, mgr.AddRule(ctx, zone, r)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) }) + + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), + "a rule must read back carrying its priority") + other := &Rule{Family: IPv4, Proto: TCP, Port: 5100, Action: Accept, Priority: 20} + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), other, mgr.Capabilities().Output), + "a rule differing only in priority must be a distinct rule") + + require.NoError(t, mgr.RemoveRule(ctx, zone, r)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), "rule still present after removal") + }) + + // --- NAT ------------------------------------------------------------------ + + t.Run("nat", func(t *testing.T) { + requireCap(t, caps.NAT) + // Each entry is a kind with one or more forms; the first the backend accepts + // (not rejected with ErrUnsupportedNAT) is round-tripped. A kind no backend + // form accepts is skipped — e.g. pf has no standalone Redirect, and masquerade + // takes an interface on pf/nft/iptables but firewalld forbids one. + natVariants := [][]*NATRule{ + {{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}}, + {{Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080}}, + {{Kind: SNAT, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"}}, + { + {Kind: Masquerade, Interface: "eth1"}, + {Kind: Masquerade}, + }, + } + for _, variants := range natVariants { + variants := variants + t.Run(variants[0].Kind.String(), func(t *testing.T) { + roundTripNATVariants(t, ctx, mgr, zone, variants) + }) + } + }) + + t.Run("natportlist", func(t *testing.T) { + requireCap(t, caps.NAT) + // A DNAT matching a discrete port list: nftables/iptables store it as one + // multi-port row; firewalld and pf fan it into one translation per port, + // each to the same target; ufw coalesces adjacent ports into a range. The + // read-back is checked by coverage: the rows must cover the rule and none + // may widen it; one remove clears every port. + rule := &NATRule{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 6401, End: 6401}, {Start: 6402, End: 6402}}, ToAddress: "10.9.1.5", ToPort: 8443} + match := func(r *NATRule) bool { + return len(r.PortSpecs()) > 0 && rule.Covers(r) + } + + if err := mgr.AddNATRule(ctx, zone, rule); errors.Is(err, ErrUnsupportedNAT) { + t.Skip("backend cannot express a multi-port dnat") + } else { + require.NoError(t, err) + } + t.Cleanup(func() { + for _, r := range natRulesOf(t, ctx, mgr, zone) { + if match(r) { + _ = mgr.RemoveNATRule(ctx, zone, r) + } + } + }) + + found := func() []*NATRule { + var out []*NATRule + for _, r := range natRulesOf(t, ctx, mgr, zone) { + if match(r) { + out = append(out, r) + } + } + return out + } + + got := found() + require.NotEmpty(t, got, "the rule must read back") + require.True(t, rule.CoveredBy(got), "the stored rows must cover every port, got %+v", got) + for _, r := range got { + require.True(t, rule.Covers(r), "a stored row must not widen the rule: %+v", r) + } + + require.NoError(t, mgr.AddNATRule(ctx, zone, rule)) + require.Len(t, found(), len(got), "re-adding the rule must not duplicate its rows") + + require.NoError(t, mgr.RemoveNATRule(ctx, zone, rule)) + require.Empty(t, found(), "removing the rule must clear every port") + }) + + t.Run("natfamilypairremove", func(t *testing.T) { + requireCap(t, caps.NAT) + // A v4 masquerade and its v6 twin on the same interface may be one row (nft's + // inet table, a pf rule with no af) or two. Removing every rule the backend + // reports must clear them all, not stop at the first match and leave the IPv6 + // twin loaded (pf), mirroring the filter-side familypairremove probe. + v4 := &NATRule{Kind: Masquerade, Family: IPv4, Interface: "eth1"} + v6 := &NATRule{Kind: Masquerade, Family: IPv6, Interface: "eth1"} + added := 0 + for _, r := range []*NATRule{v4, v6} { + err := mgr.AddNATRule(ctx, zone, r) + if errors.Is(err, ErrUnsupportedNAT) { + continue // the backend cannot express this family of an interface masquerade. + } + require.NoError(t, err) + added++ + } + if added < 2 { + t.Skip("backend does not express both families of an interface masquerade") + } + t.Cleanup(func() { + _ = mgr.RemoveNATRule(ctx, zone, v4) + _ = mgr.RemoveNATRule(ctx, zone, v6) + }) + + // Remove every masquerade the backend reports for this interface (one + // family-agnostic rule, or one per family), then confirm none remain. + isMasq := func(r *NATRule) bool { return r.Kind == Masquerade && r.Interface == "eth1" } + nats, err := mgr.GetNATRules(ctx, zone) + require.NoError(t, err) + for _, r := range nats { + if isMasq(r) { + require.NoError(t, mgr.RemoveNATRule(ctx, zone, r)) + } + } + nats, err = mgr.GetNATRules(ctx, zone) + require.NoError(t, err) + for _, r := range nats { + require.False(t, isMasq(r), "masquerade still present after removal: %+v", r) + } + }) + + // --- rule ordering -------------------------------------------------------- + + t.Run("ordering", func(t *testing.T) { + requireCap(t, caps.RuleOrdering) + r1 := &Rule{Family: IPv4, Proto: TCP, Port: 3001, Action: Accept} + r2 := &Rule{Family: IPv4, Proto: TCP, Port: 3002, Action: Accept} + r3 := &Rule{Family: IPv4, Proto: TCP, Port: 3003, Action: Accept} + byPort := map[uint16]*Rule{3001: r1, 3002: r2, 3003: r3} + for _, r := range []*Rule{r1, r2, r3} { + require.NoError(t, mgr.AddRule(ctx, zone, r)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) }) + } + + // Whether AddRule appends or prepends is backend-specific (nft appends, + // iptables inserts at the top), so read the actual order rather than assume + // it, then verify MoveRule relocates a rule relative to that order. + order0 := managedPorts(t, ctx, mgr, zone, []uint16{3001, 3002, 3003}) + require.Len(t, order0, 3) + + // Move the currently-first rule to the end: it should land last while the + // other two keep their relative order. + require.NoError(t, mgr.MoveRule(ctx, zone, byPort[order0[0]], 3)) + want := []uint16{order0[1], order0[2], order0[0]} + got := managedPorts(t, ctx, mgr, zone, []uint16{3001, 3002, 3003}) + require.Equal(t, want, got, "MoveRule to the end should relocate the first rule") + + // Insert a new rule at the front. + r0 := &Rule{Family: IPv4, Proto: TCP, Port: 3000, Action: Accept} + require.NoError(t, mgr.InsertRule(ctx, zone, 1, r0)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r0) }) + ports := managedPorts(t, ctx, mgr, zone, []uint16{3000, 3001, 3002, 3003}) + require.Equal(t, uint16(3000), ports[0], "InsertRule at position 1 should place the rule first") + + // GetRules populates a 1-based Number that increases in chain order, so a + // caller can read a rule's position from the returned set. + requireAscendingNumbers(t, managedNumbers(t, ctx, mgr, zone, []uint16{3000, 3001, 3002, 3003})) + }) + + t.Run("familyanymove", func(t *testing.T) { + requireCap(t, caps.RuleOrdering) + // A v4 rule and its v6 twin may occupy two physical rows. Moving them with one + // FamilyAny target must relocate BOTH as a unit: a naive move drags only one + // row and orphans the twin at its old position. + const port = 3510 + v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept} + v6 := &Rule{Family: IPv6, Proto: TCP, Port: port, Action: Accept} + added := 0 + for _, r := range []*Rule{v4, v6} { + err := mgr.AddRule(ctx, zone, r) + if errors.Is(err, ErrUnsupported) { + continue + } + require.NoError(t, err) + added++ + } + if added < 2 { + t.Skip("backend does not express both families of a bare-port accept (no twin to move)") + } + b := &Rule{Family: IPv4, Proto: TCP, Port: 3511, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, b)) + t.Cleanup(func() { + _ = mgr.RemoveRule(ctx, zone, v4) + _ = mgr.RemoveRule(ctx, zone, v6) + _ = mgr.RemoveRule(ctx, zone, b) + }) + + before := managedPorts(t, ctx, mgr, zone, []uint16{port}) + require.NotEmpty(t, before) + + // Move every row of the pair to the end with one FamilyAny target. The chain + // holds b plus the pair's rows, so a position past the last row appends. A + // backend that can store one family-agnostic row (nft's inet table) re-adds the + // target as that single row rather than the two it replaced, so the row count + // may shrink — what must hold is that b now leads, no row of the pair is left + // behind it, and both families still have coverage. + twin := &Rule{Family: FamilyAny, Proto: TCP, Port: port, Action: Accept} + require.NoError(t, mgr.MoveRule(ctx, zone, twin, len(before)+2)) + + order := managedPorts(t, ctx, mgr, zone, []uint16{port, 3511}) + require.NotEmpty(t, order) + require.Equal(t, uint16(3511), order[0], + "b must now be first: every row of the pair moved past it") + for _, p := range order[1:] { + require.EqualValues(t, port, p, "no row of the pair may be left before b") + } + require.True(t, twin.CoveredBy(rulesOf(t, ctx, mgr, zone)), + "both families must survive the move") + }) + + t.Run("insertposition", func(t *testing.T) { + requireCap(t, caps.RuleOrdering) + // GetRules reports one rule per stored row, each with the Number of its own + // position. Inserting before a rule's reported Number must land exactly there, + // whatever rows precede it. + a4 := &Rule{Family: IPv4, Proto: TCP, Port: 3520, Action: Accept} + a6 := &Rule{Family: IPv6, Proto: TCP, Port: 3520, Action: Accept} + b4 := &Rule{Family: IPv4, Proto: TCP, Port: 3521, Action: Accept} + b6 := &Rule{Family: IPv6, Proto: TCP, Port: 3521, Action: Accept} + added := 0 + for _, r := range []*Rule{a4, a6, b4, b6} { + err := mgr.AddRule(ctx, zone, r) + if errors.Is(err, ErrUnsupported) { + continue + } + require.NoError(t, err) + added++ + } + if added < 4 { + t.Skip("backend does not express both families of both bare-port pairs") + } + c := &Rule{Family: IPv4, Proto: TCP, Port: 3522, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, c)) + t.Cleanup(func() { + for _, r := range []*Rule{a4, a6, b4, b6, c} { + _ = mgr.RemoveRule(ctx, zone, r) + } + }) + + // Read c's Number (its position, whatever the backend's add order) and insert + // d there. c is IPv4-only, so it reads back as exactly one rule. + nums := managedNumbers(t, ctx, mgr, zone, []uint16{3522}) + require.Len(t, nums, 1, "c is IPv4-only and reads back as one rule") + cNum := nums[0] + + d := &Rule{Family: IPv4, Proto: TCP, Port: 3523, Action: Accept} + require.NoError(t, mgr.InsertRule(ctx, zone, cNum, d)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, d) }) + + // d must land immediately before c. + order := managedPorts(t, ctx, mgr, zone, []uint16{3520, 3521, 3522, 3523}) + var ci int + for i, p := range order { + if p == 3522 { + ci = i + } + } + require.Greater(t, ci, 0, "d must be inserted before c, so c is not first") + require.Equal(t, uint16(3523), order[ci-1], "d must land immediately before c") + }) + + // --- NAT ordering --------------------------------------------------------- + + t.Run("natordering", func(t *testing.T) { + requireCap(t, caps.NAT) + requireCap(t, caps.RuleOrdering) + + // DNAT rules share the prerouting chain and differ only by matched port, so + // their read-back order reflects the requested positions. + n1 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 4001, ToAddress: "10.9.0.1", ToPort: 5001} + n2 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 4002, ToAddress: "10.9.0.2", ToPort: 5002} + for _, n := range []*NATRule{n1, n2} { + require.NoError(t, mgr.AddNATRule(ctx, zone, n)) + t.Cleanup(func() { _ = mgr.RemoveNATRule(ctx, zone, n) }) + } + + // Insert a third DNAT rule at the front of the chain. + n0 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 4000, ToAddress: "10.9.0.0", ToPort: 5000} + require.NoError(t, mgr.InsertNATRule(ctx, zone, 1, n0)) + t.Cleanup(func() { _ = mgr.RemoveNATRule(ctx, zone, n0) }) + + got := managedNATPorts(t, ctx, mgr, zone, []uint16{4000, 4001, 4002}) + require.Equal(t, uint16(4000), got[0], "InsertNATRule at position 1 should place the rule first") + + // GetNATRules populates a 1-based Number that increases in chain order. + requireAscendingNumbers(t, managedNATNumbers(t, ctx, mgr, zone, []uint16{4000, 4001, 4002})) + + // MoveNATRule relocates a rule relative to that order. Move n0 (currently + // first) to the end: it should land last while the other two keep their + // relative order, mirroring MoveRule on the filter side. + byPort := map[uint16]*NATRule{4000: n0, 4001: n1, 4002: n2} + order0 := managedNATPorts(t, ctx, mgr, zone, []uint16{4000, 4001, 4002}) + require.Len(t, order0, 3) + require.NoError(t, mgr.MoveNATRule(ctx, zone, byPort[order0[0]], 3)) + want := []uint16{order0[1], order0[2], order0[0]} + gotOrder := managedNATPorts(t, ctx, mgr, zone, []uint16{4000, 4001, 4002}) + require.Equal(t, want, gotOrder, "MoveNATRule to the end should relocate the first rule") + + // Numbers stay ascending after the move. + requireAscendingNumbers(t, managedNATNumbers(t, ctx, mgr, zone, []uint16{4000, 4001, 4002})) + }) + + // --- default policy ------------------------------------------------------- + + t.Run("defaultpolicy", func(t *testing.T) { + requireCap(t, caps.DefaultPolicy) + orig, err := mgr.GetDefaultPolicy(ctx, zone) + require.NoError(t, err) + require.NotNil(t, orig) + // Restore whatever was there before, no matter how the assertions go. + t.Cleanup(func() { _ = mgr.SetDefaultPolicy(ctx, zone, orig) }) + + // Flip the input policy to the opposite of its current value and read back. + target := Drop + if orig.Input == Drop { + target = Accept + } + require.NoError(t, mgr.SetDefaultPolicy(ctx, zone, &DefaultPolicy{Input: target})) + got, err := mgr.GetDefaultPolicy(ctx, zone) + require.NoError(t, err) + require.Equal(t, target, got.Input, "input default policy should reflect the set value") + + // Also exercise the forward direction on a backend that models one + // (firewalld reports only input, so it is left ActionInvalid and skipped). + if orig.Forward != ActionInvalid { + ftarget := Drop + if orig.Forward == Drop { + ftarget = Accept + } + require.NoError(t, mgr.SetDefaultPolicy(ctx, zone, &DefaultPolicy{Forward: ftarget})) + got, err := mgr.GetDefaultPolicy(ctx, zone) + require.NoError(t, err) + require.Equal(t, ftarget, got.Forward, "forward default policy should reflect the set value") + } + }) + + // A default policy set by one manager must survive a later mutation by a fresh + // manager instance (a process restart). nftables state outlives the process but a + // backend's per-instance "table ensured" flag does not, so a backend that + // re-declares its base chains on first use must not re-assert a policy and revert + // a configured default-drop — that would silently turn a default-deny firewall + // fail-open. The nft ensureTable path must preserve an existing policy. + t.Run("defaultpolicypersists", func(t *testing.T) { + requireCap(t, caps.DefaultPolicy) + orig, err := mgr.GetDefaultPolicy(ctx, zone) + require.NoError(t, err) + require.NotNil(t, orig) + t.Cleanup(func() { _ = mgr.SetDefaultPolicy(ctx, zone, orig) }) + + // Set input to the opposite of its current value, then reconcile via a fresh + // manager whose first act is a mutating call (which triggers any lazy + // table/chain setup). + target := Drop + if orig.Input == Drop { + target = Accept + } + require.NoError(t, mgr.SetDefaultPolicy(ctx, zone, &DefaultPolicy{Input: target})) + + fresh, err := reconstruct(ctx) + require.NoError(t, err) + defer func() { _ = fresh.Close(ctx) }() + + probe := &Rule{Proto: TCP, Port: 65510, Action: Accept} + require.NoError(t, fresh.AddRule(ctx, zone, probe)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, probe) }) + + got, err := fresh.GetDefaultPolicy(ctx, zone) + require.NoError(t, err) + require.Equal(t, target, got.Input, + "a mutating call on a fresh manager must not revert the configured input default policy") + }) + + // --- address sets --------------------------------------------------------- + + t.Run("addresssets", func(t *testing.T) { + requireCap(t, caps.AddressSets) + set := &AddressSet{Name: integrationPrefix + "set", Family: IPv4, Type: SetHashIP} + require.NoError(t, mgr.AddAddressSet(ctx, set)) + t.Cleanup(func() { _ = mgr.RemoveAddressSet(ctx, set.Name) }) + + require.NoError(t, mgr.AddAddressSetEntry(ctx, set.Name, "192.0.2.10")) + sets, err := mgr.GetAddressSets(ctx) + require.NoError(t, err) + got := findSet(sets, set.Name) + require.NotNil(t, got, "created set %q not found in %+v", set.Name, sets) + require.Contains(t, got.Entries, "192.0.2.10") + + // A backend that stages its sets only programs the kernel here, so this is + // what proves the staged form actually loads (iptables/ufw feed an `ipset + // restore` script; csf/apf source their hook). The set must still read back + // exactly as staged afterwards: a reload that rebuilt the staged state from + // the live kernel would drag in every unrelated set the host happens to hold. + require.NoError(t, mgr.Reload(ctx), "reload must succeed with a staged address set present") + sets, err = mgr.GetAddressSets(ctx) + require.NoError(t, err) + got = findSet(sets, set.Name) + require.NotNil(t, got, "set %q missing after reload", set.Name) + require.Equal(t, []string{"192.0.2.10"}, got.Entries, + "the set must read back exactly as staged after a reload") + + // A rule may match on the set by naming it in Source: a non-address token is + // translated to the backend's set-match syntax and round-trips. + setRule := &Rule{Family: IPv4, Proto: TCP, Port: 4200, Source: set.Name, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, setRule)) + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), setRule, mgr.Capabilities().Output), + "rule matching on set %q not found", set.Name) + require.NoError(t, mgr.RemoveRule(ctx, zone, setRule)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), setRule, mgr.Capabilities().Output), + "set-matching rule still present after removal") + + // A FamilyAny set reference is accepted and pinned to the set's own family + // rather than rejected: the set is family-typed, so the rule could never + // match the other family anyway. containsRule compares family-agnostically, + // so the pinned read-back still satisfies the FamilyAny target, and the + // same target removes it. + anyRule := &Rule{Proto: TCP, Port: 4202, Source: set.Name, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, anyRule), + "a FamilyAny set reference must be pinned to the set's family, not rejected") + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), anyRule, mgr.Capabilities().Output), + "FamilyAny rule matching on set %q not found", set.Name) + require.NoError(t, mgr.RemoveRule(ctx, zone, anyRule)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), anyRule, mgr.Capabilities().Output), + "FamilyAny set-matching rule still present after removal") + + require.NoError(t, mgr.RemoveAddressSetEntry(ctx, set.Name, "192.0.2.10")) + sets, err = mgr.GetAddressSets(ctx) + require.NoError(t, err) + if got = findSet(sets, set.Name); got != nil { + require.NotContains(t, got.Entries, "192.0.2.10", "entry should be gone after removal") + } + + require.NoError(t, mgr.RemoveAddressSet(ctx, set.Name)) + sets, err = mgr.GetAddressSets(ctx) + require.NoError(t, err) + require.Nil(t, findSet(sets, set.Name), "set should be gone after removal") + + // The kernel-side destroy a staging backend owes for a removed set is + // performed here, after the rules that could reference it are reloaded. + require.NoError(t, mgr.Reload(ctx), "reload must succeed after removing a staged address set") + sets, err = mgr.GetAddressSets(ctx) + require.NoError(t, err) + require.Nil(t, findSet(sets, set.Name), "set came back after the reload that should have destroyed it") + + // A missing set is a well-defined not-found condition, not a generic + // error: GetAddressSet must report it, RemoveAddressSet/entry ops on it + // must not spuriously fail, and re-removing an already-gone set is a + // no-op rather than an error. + _, err = mgr.GetAddressSet(ctx, set.Name) + require.Error(t, err, "GetAddressSet on a nonexistent set must report not-found") + require.NoError(t, mgr.RemoveAddressSet(ctx, set.Name), + "removing an already-gone set must be a no-op") + + // Removing a set that is still referenced by a rule must not falsely report + // success. Either the backend removes the set (and it is gone) or it returns + // an error — it must never return nil while the set remains. A backend that + // stages sets unstages this one cleanly, since the rule referencing it is + // staged too and neither is live yet; one acting on the kernel directly has + // to surface `ipset destroy`'s "in use by a kernel component" rather than + // swallow it. + inuse := &AddressSet{Name: integrationPrefix + "inuse", Family: IPv4, Type: SetHashIP} + require.NoError(t, mgr.AddAddressSet(ctx, inuse)) + t.Cleanup(func() { _ = mgr.RemoveAddressSet(ctx, inuse.Name) }) + ref := &Rule{Family: IPv4, Proto: TCP, Port: 4201, Source: inuse.Name, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, ref)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, ref) }) + if err := mgr.RemoveAddressSet(ctx, inuse.Name); err == nil { + sets, err = mgr.GetAddressSets(ctx) + require.NoError(t, err) + require.Nil(t, findSet(sets, inuse.Name), + "RemoveAddressSet reported success while the referenced set is still present") + } + // With the referencing rule gone, the set removes cleanly. + require.NoError(t, mgr.RemoveRule(ctx, zone, ref)) + require.NoError(t, mgr.RemoveAddressSet(ctx, inuse.Name)) + }) + + // --- sync ------------------------------------------------------------------- + + t.Run("sync", func(t *testing.T) { + // Sync reconciles the zone toward a desired set. Desired is built as the + // CURRENT state plus two new rules, so the test never strips rules the + // environment depends on (a run may arrive over SSH), and the three + // contract points run end-to-end: missing rules are added, a second Sync + // is a no-op whatever rows the backend fanned the rules into (the diff is + // the coverage relation, not row equality), and a rule outside desired — + // freshly added here, but a foreign rule is reconciled the same way — is + // removed. + r1 := &Rule{Proto: TCP, Port: 5601, Action: Accept} + r2 := &Rule{Proto: TCP, Port: 5602, Action: Accept} + t.Cleanup(func() { + _ = mgr.RemoveRule(ctx, zone, r1) + _ = mgr.RemoveRule(ctx, zone, r2) + }) + desired := append(rulesOf(t, ctx, mgr, zone), r1, r2) + + added, removed, err := Sync(ctx, mgr, zone, desired) + require.NoError(t, err) + require.Equal(t, 2, added, "Sync must add exactly the two missing rules") + require.Zero(t, removed, "Sync must keep every rule desired covers") + rules := rulesOf(t, ctx, mgr, zone) + require.True(t, containsRule(rules, r1, caps.Output), "r1 missing after Sync") + require.True(t, containsRule(rules, r2, caps.Output), "r2 missing after Sync") + + // Sync against its own output is a no-op, whichever rows the backend chose + // to store the rules as. + added, removed, err = Sync(ctx, mgr, zone, desired) + require.NoError(t, err) + require.Zero(t, added, "a second Sync must add nothing, or every run churns") + require.Zero(t, removed, "a second Sync must remove nothing, or every run churns") + + // A rule desired does not cover is removed. + r3 := &Rule{Proto: TCP, Port: 5603, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, r3)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r3) }) + added, removed, err = Sync(ctx, mgr, zone, desired) + require.NoError(t, err) + require.Zero(t, added, "reconciling away an undesired rule must not re-add anything") + require.NotZero(t, removed, "Sync must remove the rule desired does not cover") + rules = rulesOf(t, ctx, mgr, zone) + require.False(t, containsRule(rules, r3, caps.Output), "undesired rule still present after Sync") + require.True(t, containsRule(rules, r1, caps.Output), "r1 must survive the reconcile") + require.True(t, containsRule(rules, r2, caps.Output), "r2 must survive the reconcile") + }) + + // A full replace is just Sync toward the desired set, which the sync subtest + // above covers non-destructively; a destructive full replace against the shared + // integration environment would strip rules other subtests depend on. + + // --- backup / restore ----------------------------------------------------- + + t.Run("backup", func(t *testing.T) { + r1 := &Rule{Proto: TCP, Port: 4001, Action: Accept} + r2 := &Rule{Proto: TCP, Port: 4002, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, r1)) + require.NoError(t, mgr.AddRule(ctx, zone, r2)) + t.Cleanup(func() { + _ = mgr.RemoveRule(ctx, zone, r1) + _ = mgr.RemoveRule(ctx, zone, r2) + }) + + backup, err := mgr.Backup(ctx, zone) + require.NoError(t, err) + require.NotNil(t, backup) + + // Drop one rule, then restore and confirm it is back. + require.NoError(t, mgr.RemoveRule(ctx, zone, r1)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), r1, mgr.Capabilities().Output)) + + require.NoError(t, mgr.Restore(ctx, zone, backup)) + rules := rulesOf(t, ctx, mgr, zone) + require.True(t, containsRule(rules, r1, mgr.Capabilities().Output), "restored rule r1 missing") + require.True(t, containsRule(rules, r2, mgr.Capabilities().Output), "restored rule r2 missing") + + // Restore reconciles to the backup: a rule added after the snapshot (and so + // absent from it) must be removed, not left in place. This guards the ufw + // Restore touching both the backup's own rules and any current rule missing + // from the backup. + r3 := &Rule{Proto: TCP, Port: 4003, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, r3)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r3) }) + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r3, mgr.Capabilities().Output), "r3 should be present before reconcile restore") + + require.NoError(t, mgr.Restore(ctx, zone, backup)) + rules = rulesOf(t, ctx, mgr, zone) + require.True(t, containsRule(rules, r1, mgr.Capabilities().Output), "r1 missing after reconcile restore") + require.True(t, containsRule(rules, r2, mgr.Capabilities().Output), "r2 missing after reconcile restore") + require.False(t, containsRule(rules, r3, mgr.Capabilities().Output), "r3 was not in the backup and must be removed by Restore") + }) + + t.Run("restoreorder", func(t *testing.T) { + // Restore must reproduce the backed-up rule order on backends whose filter + // rules evaluate first-match in chain order. ufw's Restore re-added rules in + // forward order while AddRule prepends, silently reversing them — so a + // specific deny backed up above a broad allow came back below it and never + // fired. Compare each backend against itself: the order right after the adds + // must equal the order after a backup/remove/restore cycle. Gated on + // RuleOrdering: a backend whose filter rules form a first-match chain + // advertises it, while the list/zone-model backends (csf, apf, firewalld, + // wf) do not order rules this way. + requireCap(t, caps.RuleOrdering) + ports := []uint16{4101, 4102, 4103} + var rules []*Rule + for _, p := range ports { + r := &Rule{Family: IPv4, Proto: TCP, Port: p, Action: Accept} + rules = append(rules, r) + require.NoError(t, mgr.AddRule(ctx, zone, r)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) }) + } + + before := managedPorts(t, ctx, mgr, zone, ports) + require.Len(t, before, 3, "all three rules should be present before backup") + + backup, err := mgr.Backup(ctx, zone) + require.NoError(t, err) + for _, r := range rules { + require.NoError(t, mgr.RemoveRule(ctx, zone, r)) + } + require.NoError(t, mgr.Restore(ctx, zone, backup)) + + after := managedPorts(t, ctx, mgr, zone, ports) + require.Equal(t, before, after, "Restore must preserve the backed-up rule order") + }) + + t.Run("backupstate", func(t *testing.T) { + // Backup must capture — and Restore must reconstruct — more than filter/NAT + // rules: the default policy and the managed address sets. Dropping either + // loses a restrictive default policy on a restore onto a fresh host and + // leaves a set-referencing rule dangling. + if !caps.AddressSets && !caps.DefaultPolicy { + t.Skip("backend captures neither an address set nor a default policy") + } + + // Seed an address set (with an entry) so the backup has one to capture. + var set *AddressSet + if caps.AddressSets { + set = &AddressSet{Name: integrationPrefix + "bk", Family: IPv4, Type: SetHashNet, Entries: []string{"192.0.2.0/24"}} + require.NoError(t, mgr.AddAddressSet(ctx, set)) + t.Cleanup(func() { _ = mgr.RemoveAddressSet(ctx, set.Name) }) + } + + // Set a known default policy (the opposite of the current input action, so the + // later assertion is meaningful) and remember the original to restore. + var policyTarget Action + if caps.DefaultPolicy { + orig, err := mgr.GetDefaultPolicy(ctx, zone) + require.NoError(t, err) + require.NotNil(t, orig) + t.Cleanup(func() { _ = mgr.SetDefaultPolicy(ctx, zone, orig) }) + policyTarget = Drop + if orig.Input == Drop { + policyTarget = Accept + } + require.NoError(t, mgr.SetDefaultPolicy(ctx, zone, &DefaultPolicy{Input: policyTarget})) + } + + backup, err := mgr.Backup(ctx, zone) + require.NoError(t, err) + require.NotNil(t, backup) + + // Mutate the state away from the snapshot: delete the set, flip the policy. + if caps.AddressSets { + require.NotNil(t, findSet(backup.AddressSets, set.Name), "backup did not capture the address set") + require.NoError(t, mgr.RemoveAddressSet(ctx, set.Name)) + } + if caps.DefaultPolicy { + require.NotNil(t, backup.DefaultPolicy, "backup did not capture the default policy") + flip := Accept + if policyTarget == Accept { + flip = Drop + } + require.NoError(t, mgr.SetDefaultPolicy(ctx, zone, &DefaultPolicy{Input: flip})) + } + + // Restore must bring both back. + require.NoError(t, mgr.Restore(ctx, zone, backup)) + + if caps.AddressSets { + got, err := mgr.GetAddressSet(ctx, set.Name) + require.NoError(t, err, "restored address set missing") + require.ElementsMatch(t, set.Entries, got.Entries, "restored address set entries mismatch") + } + if caps.DefaultPolicy { + got, err := mgr.GetDefaultPolicy(ctx, zone) + require.NoError(t, err) + require.Equal(t, policyTarget, got.Input, "restored default input policy mismatch") + } + }) + + t.Run("zones", func(t *testing.T) { + requireCap(t, caps.Zones) + // A zones backend maps interfaces to zones: GetZone with no interface + // names the default zone, an interface bound to another zone (seeded out + // of band, permanent config only) resolves to that zone, and an unbound + // interface keeps resolving to the default. The binding targets the + // trusted zone so an accidental runtime activation cannot filter + // anything away. + def, err := mgr.GetZone(ctx, "") + require.NoError(t, err) + require.NotEmpty(t, def, "a zones backend must name its default zone") + + seeder := zoneInterfaceSeeder(mgr) + if seeder == nil { + t.Skip("no out-of-band interface-to-zone seeder for this backend") + } + target := "trusted" + if def == target { + target = "work" + } + undo, err := seeder("lo", target) + if err != nil { + t.Skipf("could not bind an interface to zone %q: %v", target, err) + } + t.Cleanup(undo) + + got, err := mgr.GetZone(ctx, "lo") + require.NoError(t, err) + require.Equal(t, target, got, "GetZone must resolve a bound interface to its zone") + + unbound, err := mgr.GetZone(ctx, "gofwit0") + require.NoError(t, err) + require.Equal(t, def, unbound, "an unbound interface must resolve to the default zone") + }) + + t.Run("rulecounters", func(t *testing.T) { + requireCap(t, caps.RuleCounters) + // A backend advertising RuleCounters must populate Packets/Bytes from the + // live firewall. Drive real traffic through a managed rule: an egress + // accept toward a TEST-NET address, dialed with a short timeout — the SYN + // leaves through the real interface, so it traverses the egress path even + // where loopback is exempt from filtering (the FreeBSD harness pf.conf + // skips lo0). Counting on an egress rule needs the direction, and every + // RuleCounters backend also distinguishes output. + requireCap(t, caps.Output) + r := &Rule{Direction: DirOutput, Family: IPv4, Proto: TCP, Port: 39321, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, zone, r)) + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) }) + // Activate the rule where the backend programs the kernel on reload + // (iptables' save files load through its restore service). + require.NoError(t, mgr.Reload(ctx)) + + counted := func() bool { + for _, got := range rulesOf(t, ctx, mgr, zone) { + if got.EqualBase(r, mgr.Capabilities().Output) && got.Packets > 0 { + return true + } + } + return false + } + // The dial itself fails — nothing answers a TEST-NET address — and only + // the attempt matters: each SYN that leaves must hit the rule's counter. + // Retry until the deadline, since a backend may report counters with some + // latency. + deadline := time.Now().Add(10 * time.Second) + for !counted() { + conn, derr := net.DialTimeout("tcp", "192.0.2.1:39321", 500*time.Millisecond) + if derr == nil { + _ = conn.Close() + } + require.False(t, time.Now().After(deadline), + "the managed rule's Packets counter never became non-zero after driving traffic at it") + } + }) + + t.Run("zonescope", func(t *testing.T) { + // A zone-scoped backend keeps a separate rule space per zone — firewalld's + // zones, wf's firewall profiles. AddRule and GetRules scope to the named + // zone, so RemoveRule must too: removing a rule from one zone must not + // delete an identical rule in another. The scoping is probed rather than + // gated on a backend: the first candidate zone name the backend accepts is + // the base, and a second qualifies only if the base zone's rule is not + // already visible there — a backend with one shared rule space (everything + // but firewalld and wf) never finds a distinct second zone and skips. + r := &Rule{Proto: TCP, Port: 5303, Action: Accept} + candidates := []string{"public", "private", "home", "work", "domain", "internal", "dmz"} + + var zoneA, zoneB string + for i, z := range candidates { + if err := mgr.AddRule(ctx, z, r); err != nil { + continue + } + zoneA = z + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zoneA, r) }) + for _, z2 := range candidates[i+1:] { + rs, err := mgr.GetRules(ctx, z2) + if err != nil || containsRule(rs, r, caps.Output) { + continue // an unknown zone, or one sharing zoneA's rule space. + } + if err := mgr.AddRule(ctx, z2, r); err != nil { + continue + } + zoneB = z2 + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zoneB, r) }) + break + } + break + } + if zoneA == "" || zoneB == "" { + t.Skip("backend does not scope rules to two distinct zones") + } + + require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneA), r, caps.Output), "rule should be present in zone %q", zoneA) + require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneB), r, caps.Output), "rule should be present in zone %q", zoneB) + + // Remove from the second zone only; the first zone's copy must survive. + require.NoError(t, mgr.RemoveRule(ctx, zoneB, r)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zoneB), r, caps.Output), "rule should be gone from zone %q", zoneB) + require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneA), r, caps.Output), + "removing the rule from zone %q must not delete the copy in zone %q", zoneB, zoneA) + }) + + t.Run("foreignmacsource", func(t *testing.T) { + // A backend may store a MAC zone source (firewalld), which GetRules reports + // as a bare-source rule. RemoveRule's source shortcut must handle a MAC + // address, not only an IP/CIDR, so a foreign MAC source can be removed and + // Sync/Restore converge on it. Seed one out of band in the permanent config the + // backend reads and confirm the library both surfaces and removes it. The + // generic foreign-rule sweep is the foreignrule subtest; the seeding + // commands are inherently backend-specific (foreignMACSeeder), so a backend + // without a seeder skips, exactly like foreignrule. + seeder := foreignMACSeeder(mgr) + if seeder == nil { + t.Skip("no out-of-band MAC zone-source seeder for this backend") + } + seed, err := seeder(zone) + if err != nil { + t.Skipf("could not seed a foreign MAC source: %v", err) + } + t.Cleanup(seed.undo) + + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output), + "a foreign MAC zone source should surface in GetRules") + + require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output), + "RemoveRule must remove a foreign MAC zone source") + }) + + t.Run("foreignprotocol", func(t *testing.T) { + // A backend may store a bare-protocol allow as its own construct distinct + // from the rule form the library writes (firewalld's zone protocol entries, + // seeded with firewall-cmd --add-protocol). GetRules must surface it and + // RemoveRule must remove it, or a foreign protocol allow is invisible to + // Sync/Restore. Like foreignmacsource, the seeding is backend-specific + // (foreignProtocolSeeder) and a backend without a seeder skips. + seeder := foreignProtocolSeeder(mgr) + if seeder == nil { + t.Skip("no out-of-band protocol-entry seeder for this backend") + } + seed, err := seeder(zone) + if err != nil { + t.Skipf("could not seed a foreign protocol: %v", err) + } + t.Cleanup(seed.undo) + + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output), + "a foreign zone protocol should surface in GetRules") + + require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output), + "RemoveRule must remove a foreign zone protocol") + }) + + t.Run("afterrulesexcluded", func(t *testing.T) { + // A backend may keep a raw rules file it deliberately does not manage (ufw's + // after.rules; the library writes raw rules only into before.rules). GetRules + // must not surface a rule from it — otherwise Backup captures it and Restore + // re-adds it into the managed file, duplicating it. The seeding edits the + // backend's own file (unmanagedRawRuleSeeder), so a backend without one skips. + seeder := unmanagedRawRuleSeeder(mgr) + if seeder == nil { + t.Skip("backend has no unmanaged raw rules file to seed") + } + probe, undo, err := seeder() + if err != nil { + t.Skipf("could not seed the unmanaged raw rule: %v", err) + } + t.Cleanup(undo) + + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), probe, mgr.Capabilities().Output), + "a rule in an unmanaged raw rules file must not be surfaced by GetRules") + }) +} + +// --- helpers ---------------------------------------------------------------- + +// requireCap skips the current subtest when the backend does not advertise the +// feature under test. +func requireCap(t *testing.T, supported bool) { + t.Helper() + if !supported { + t.Skip("feature not supported by this backend") + } +} + +// roundTripRule adds a rule, confirms it reads back, removes it, and confirms it +// is gone. A t.Cleanup guards against a mid-test failure leaving the rule behind. +func roundTripRule(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) { + t.Helper() + require.NoError(t, mgr.AddRule(ctx, zone, rule)) + roundTripAdded(t, ctx, mgr, zone, rule) +} + +// roundTripRuleOrSkip is roundTripRule for a shape not every backend can express: +// a rejection with the ErrUnsupported sentinel skips the subtest instead of +// failing it. A skip marks a real expressiveness gap — a feature the backend +// might expose (or a capability that should advertise it) — while any other +// failure is still a bug. +func roundTripRuleOrSkip(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) { + t.Helper() + err := mgr.AddRule(ctx, zone, rule) + if errors.Is(err, ErrUnsupported) { + t.Skipf("backend cannot express this shape: %v", err) + } + require.NoError(t, err) + roundTripAdded(t, ctx, mgr, zone, rule) +} + +// roundTripAdded finishes a round trip for a rule AddRule already accepted: it +// must read back, remove, and read back as gone. +func roundTripAdded(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) { + t.Helper() + t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) }) + + rules := rulesOf(t, ctx, mgr, zone) + require.True(t, containsRule(rules, rule, mgr.Capabilities().Output), "added rule %+v not found in %s", rule, dumpRules(rules)) + + require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output), "rule still present after removal") +} + +// portCovers reports whether the row r is one the port-set rule spans: the +// rule's coverage contains it (its ports lie within the rule's port set and its +// other match fields match), so a row reads back the same whether the backend +// stored the list as one multi-port row, fanned it into one row per port, or +// (ufw) coalesced adjacent discrete ports into a range. A foreign rule the +// port set happens to contain would also match, so the probe ports are kept +// uncommon. +func portSetMatches(rule, r *Rule) bool { + return len(r.PortSpecs()) > 0 && rule.Covers(r) +} + +// roundTripPortSet round-trips a rule whose port list a backend may store as +// one multi-port row (nftables, iptables, ufw, wf, the csf/apf hooks), fan into +// one row per port (firewalld, pf), or coalesce adjacent discrete ports into a +// range (ufw). GetRules reports whichever rows the backend actually holds, so +// the read-back is checked by coverage: the rows must cover the rule and none +// may widen it. Re-adding must not duplicate, and one remove must clear every +// row the rule spans. +func roundTripPortSet(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) { + t.Helper() + match := func(r *Rule) bool { return portSetMatches(rule, r) } + require.NoError(t, mgr.AddRule(ctx, zone, rule)) + t.Cleanup(func() { + for _, r := range rulesOf(t, ctx, mgr, zone) { + if match(r) { + _ = mgr.RemoveRule(ctx, zone, r) + } + } + }) + + found := func() []*Rule { + var out []*Rule + for _, r := range rulesOf(t, ctx, mgr, zone) { + if match(r) { + out = append(out, r) + } + } + return out + } + + got := found() + require.NotEmpty(t, got, "the rule must read back") + require.True(t, rule.CoveredBy(got), "the stored rows must cover every port, got %+v", got) + for _, r := range got { + require.True(t, rule.Covers(r), "a stored row must not widen the rule: %+v", r) + } + + // Re-adding the same rule is a no-op: every row it spans dedups against what + // is already stored. + require.NoError(t, mgr.AddRule(ctx, zone, rule)) + require.Len(t, found(), len(got), "re-adding the rule must not duplicate its rows") + + // One remove clears every port the rule spans. + require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) + require.Empty(t, found(), "removing the rule must clear every port") +} + +// roundTripVariants tries each rule form in order and round-trips the first one +// the backend accepts, skipping a form the backend rejects with an ErrUnsupported +// sentinel. It lets a single probe cover backends that express the same +// capability in different forms — e.g. nft matches a global connection limit while +// apf only expresses a per-source one, and nft matches bare ICMP while apf +// requires a type. It fails if the backend rejects every form as unsupported, +// since the capability under test claimed the feature works. +func roundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) { + t.Helper() + if !tryRoundTripVariants(t, ctx, mgr, zone, variants...) { + t.Fatal("backend advertised the capability but rejected every probe form as unsupported") + } +} + +// tryRoundTripVariants round-trips the first variant the backend accepts and +// reports whether any was accepted. A variant rejected with an ErrUnsupported +// sentinel moves on to the next; any other add error fails the test. +func tryRoundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) bool { + t.Helper() + for _, rule := range variants { + err := mgr.AddRule(ctx, zone, rule) + if errors.Is(err, ErrUnsupported) { + continue // the backend cannot express this form; try the next. + } + require.NoError(t, err) + roundTripAdded(t, ctx, mgr, zone, rule) + return true + } + return false +} + +// roundTripNATVariants tries each NAT rule form and round-trips the first the +// backend accepts (not rejected with ErrUnsupportedNAT), mirroring +// roundTripVariants for filter rules. It skips the kind when the backend accepts +// no form (e.g. pf has no standalone Redirect). +func roundTripNATVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants []*NATRule) { + t.Helper() + for _, nat := range variants { + err := mgr.AddNATRule(ctx, zone, nat) + if errors.Is(err, ErrUnsupportedNAT) { + continue + } + require.NoError(t, err) + t.Cleanup(func() { _ = mgr.RemoveNATRule(ctx, zone, nat) }) + + rules, err := mgr.GetNATRules(ctx, zone) + require.NoError(t, err) + require.True(t, containsNAT(rules, nat), "added NAT rule %+v not found in %+v", nat, rules) + + require.NoError(t, mgr.RemoveNATRule(ctx, zone, nat)) + rules, err = mgr.GetNATRules(ctx, zone) + require.NoError(t, err) + require.False(t, containsNAT(rules, nat), "NAT rule still present after removal") + return + } + t.Skipf("%s does not support the %s NAT kind in any probed form", mgr.Type(), variants[0].Kind) +} + +// rulesOf reads the managed rules or fails the test. +func rulesOf(t *testing.T, ctx context.Context, mgr Manager, zone string) []*Rule { + t.Helper() + rules, err := mgr.GetRules(ctx, zone) + require.NoError(t, err) + return rules +} + +// natRulesOf reads the managed NAT rules or fails the test. +func natRulesOf(t *testing.T, ctx context.Context, mgr Manager, zone string) []*NATRule { + t.Helper() + rules, err := mgr.GetNATRules(ctx, zone) + require.NoError(t, err) + return rules +} + +// containsRule reports whether want appears in rules, compared family-agnostically +// (EqualBase) so a FamilyAny rule matches a backend that stored it under a concrete +// family, and vice versa. A DirAny read-back rule also satisfies a concrete- +// direction want: a backend whose config already covers the opposite direction +// (e.g. apf's default egress ICMP list) collapses a concrete-direction add into one +// DirAny rule, and the added rule is still present as one direction of it. +func containsRule(rules []*Rule, want *Rule, outputSupported bool) bool { + for _, r := range rules { + if r.EqualBase(want, outputSupported) { + return true + } + if outputSupported && r.Direction == DirAny && + (want.Direction == DirInput || want.Direction == DirOutput) && + r.canonicalMatch().EqualBase(want.canonicalMatch(), false) { + return true + } + } + return false +} + +// findRule returns the first managed rule matching want, failing if none do. +func findRule(t *testing.T, ctx context.Context, mgr Manager, zone string, want *Rule) *Rule { + t.Helper() + for _, r := range rulesOf(t, ctx, mgr, zone) { + if r.EqualBase(want, mgr.Capabilities().Output) { + return r + } + } + t.Fatalf("rule %+v not found", want) + return nil +} + +// managedPorts returns, in backend order, the destination ports of the managed +// rules whose port is in the wanted set. It lets ordering assertions ignore any +// unrelated rules that share the zone. +func managedPorts(t *testing.T, ctx context.Context, mgr Manager, zone string, wanted []uint16) []uint16 { + t.Helper() + want := make(map[uint16]bool, len(wanted)) + for _, p := range wanted { + want[p] = true + } + var out []uint16 + for _, r := range rulesOf(t, ctx, mgr, zone) { + if want[r.Port] { + out = append(out, r.Port) + } + } + return out +} + +// managedNATPorts returns, in backend order, the matched ports of the NAT rules +// whose port is in the wanted set, mirroring managedPorts for NAT ordering. +func managedNATPorts(t *testing.T, ctx context.Context, mgr Manager, zone string, wanted []uint16) []uint16 { + t.Helper() + want := make(map[uint16]bool, len(wanted)) + for _, p := range wanted { + want[p] = true + } + rules, err := mgr.GetNATRules(ctx, zone) + require.NoError(t, err) + var out []uint16 + for _, r := range rules { + if want[r.Port] { + out = append(out, r.Port) + } + } + return out +} + +// managedNumbers returns, in backend order, the Number of each managed rule whose +// port is in the wanted set, so an ordering assertion can check that GetRules +// populated a rule's position. +func managedNumbers(t *testing.T, ctx context.Context, mgr Manager, zone string, wanted []uint16) []int { + t.Helper() + want := make(map[uint16]bool, len(wanted)) + for _, p := range wanted { + want[p] = true + } + var out []int + for _, r := range rulesOf(t, ctx, mgr, zone) { + if want[r.Port] { + out = append(out, r.Number) + } + } + return out +} + +// managedNATNumbers is managedNumbers for NAT rules. +func managedNATNumbers(t *testing.T, ctx context.Context, mgr Manager, zone string, wanted []uint16) []int { + t.Helper() + want := make(map[uint16]bool, len(wanted)) + for _, p := range wanted { + want[p] = true + } + rules, err := mgr.GetNATRules(ctx, zone) + require.NoError(t, err) + var out []int + for _, r := range rules { + if want[r.Port] { + out = append(out, r.Number) + } + } + return out +} + +// requireAscendingNumbers asserts every number is non-zero (an ordered backend +// populates Number) and strictly increases in the given order. +func requireAscendingNumbers(t *testing.T, nums []int) { + t.Helper() + require.NotEmpty(t, nums) + for i, num := range nums { + require.NotZero(t, num, "an ordered backend must populate a rule's Number") + if i > 0 { + require.Greater(t, num, nums[i-1], "Number must increase in chain order") + } + } +} + +// containsNAT reports whether want appears in rules (family-agnostic). +func containsNAT(rules []*NATRule, want *NATRule) bool { + for _, r := range rules { + if r.EqualBase(want) { + return true + } + } + return false +} + +// dumpRules renders rules as readable multi-line %+v for failure messages. +func dumpRules(rules []*Rule) string { + if len(rules) == 0 { + return "[] (no managed rules)" + } + out := "" + for _, r := range rules { + out += fmt.Sprintf("\n %+v", r) + } + return out +} + +// findSet returns the address set with the given name, or nil. +func findSet(sets []*AddressSet, name string) *AddressSet { + for _, s := range sets { + if s.Name == name { + return s + } + } + return nil +} diff --git a/integration_windows_test.go b/integration_windows_test.go new file mode 100644 index 0000000..bcc12f3 --- /dev/null +++ b/integration_windows_test.go @@ -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) }}, + }) +} diff --git a/ipset_linux.go b/ipset_linux.go new file mode 100644 index 0000000..51c44e5 --- /dev/null +++ b/ipset_linux.go @@ -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 +} diff --git a/iptables_linux.go b/iptables_linux.go new file mode 100644 index 0000000..f1adbff --- /dev/null +++ b/iptables_linux.go @@ -0,0 +1,3449 @@ +package firewall + +import ( + "bufio" + "context" + "errors" + "fmt" + "log" + "net" + "os" + "slices" + "strconv" + "strings" + + "github.com/anmitsu/go-shlex" +) + +const ( + // IPTablesNoSave is the error text returned when no iptables save path is found. + IPTablesNoSave = "unable to find iptables save path" + // IPTablesNoService is the error text returned when no iptables service is found. + IPTablesNoService = "unable to find iptables service" +) + +// IPTables manages filter and NAT rules through iptables save files and the service that restores them. +type IPTables struct { + IP4Path, IP6Path string + // IP6Path and IP6Service are empty on a host whose packaging ships no + // ip6tables save file — a system built without IPv6, or one where the + // ip6tables package was never installed — and on one whose IPv6 restore + // service is not enabled, since nothing would replay the file at boot. Such a + // host is managed IPv4-only; see managesIPv6 for what that changes. + IP4Service, IP6Service string + // IPSetPath and IPSetService describe the optional ipset persistence + // mechanism detected for this host. IPSetPath is the file address sets are + // staged in — the authority for the sets this backend manages, loaded into the + // kernel by Reload and by IPSetService at boot, before the rules that + // reference them. Both are empty when no mechanism is installed, in which case + // address sets are created live and do not survive a reboot. + IPSetPath, IPSetService string + // pendingSetRemovals names the sets dropped from the staging file this session + // whose kernel-side destroy is still owed. Reload performs them after the rules + // services restart, so nothing references the set by then; a set that will not + // destroy stays queued. The queue is session state, so a removal staged by a + // process that exits before Reload leaves the set live until the next boot. + pendingSetRemovals []string + // rulePrefix, when set, is written as an iptables comment on rules this + // library creates so they can be told apart from pre-existing rules. + rulePrefix string +} + +// iptLayout names the save-file paths and restore services a supported iptables +// packaging uses. The Debian layout carries the same service for both families +// (netfilter-persistent restores both rules.v4 and rules.v6), so ip4Service and +// ip6Service are equal there. +type iptLayout struct { + ip4Path, ip6Path string + // ip6Path and ip6Service are cleared by probeIPTLayout when the packaging's + // IPv6 save file is absent, marking the host IPv4-only. NewIPTables clears + // them the same way when the IPv6 restore service is not enabled. + ip4Service, ip6Service string + // ipsetPath is the save file the ipset restore service reads on boot, and + // ipsetService is the service that restores it before the rules service + // loads the -m set rules that reference the sets. Persisting sets is optional + // (a missing mechanism is not fatal, unlike a missing rules save file), so + // these describe the packaging's convention; NewIPTables confirms the + // mechanism is installed. + ipsetPath, ipsetService string + // ipsetPlugin, when set, is a glob whose presence proves the restore mechanism + // is installed. The Debian layout persists sets through a netfilter-persistent + // plugin rather than a dedicated service, so its absence means the saved file + // would never be restored and the sets are left live-only. + ipsetPlugin string +} + +// iptLayouts lists the iptables packagings this backend understands, in probe +// order. Debian precedes Arch because both keep their save files in +// /etc/iptables under different names, so the order only decides which wins on +// a host that somehow carries both. +var iptLayouts = []iptLayout{ + // RHEL/iptables-services: per-family save files and services, sets restored + // by a dedicated ipset service. + { + ip4Path: "/etc/sysconfig/iptables", ip6Path: "/etc/sysconfig/ip6tables", + ip4Service: "iptables", ip6Service: "ip6tables", + ipsetPath: "/etc/sysconfig/ipset", ipsetService: "ipset", + }, + // Debian/Ubuntu iptables-persistent: the single netfilter-persistent service + // restores both families, and sets ride one of its plugins. + { + ip4Path: "/etc/iptables/rules.v4", ip6Path: "/etc/iptables/rules.v6", + ip4Service: "netfilter-persistent", ip6Service: "netfilter-persistent", + ipsetPath: "/etc/iptables/ipsets", ipsetService: "netfilter-persistent", + ipsetPlugin: "/usr/share/netfilter-persistent/plugins.d/*ipset*", + }, + // Arch/Manjaro: the iptables package's own per-family units restore + // /etc/iptables/*.rules, and the ipset package's unit restores + // /etc/ipset.conf. + { + ip4Path: "/etc/iptables/iptables.rules", ip6Path: "/etc/iptables/ip6tables.rules", + ip4Service: "iptables", ip6Service: "ip6tables", + ipsetPath: "/etc/ipset.conf", ipsetService: "ipset", + }, +} + +// managesIPv6 reports whether this host has an ip6tables save file to manage and +// a service to restore it. When it does not, the backend runs IPv4-only: reads +// report IPv4 rows only, FamilyAny writes narrow to the IPv4 file, and a +// concrete-IPv6 write is rejected through checkFamilyManaged. Removals are not +// gated — with no v6 file there is nothing v6 to remove, so they are no-ops +// rather than errors. +func (f *IPTables) managesIPv6() bool { + return f.IP6Path != "" +} + +// probeIPTLayout reports the first layout in iptLayouts whose v4 save file is +// present under root. The v4 file alone decides the match: a layout whose v6 +// partner is absent is reported with its ip6Path and ip6Service cleared, marking +// the host IPv4-only, since a system built without IPv6 still has an IPv4 +// firewall worth managing. +func probeIPTLayout(root string) (iptLayout, bool) { + for _, l := range iptLayouts { + if _, err := os.Stat(root + l.ip4Path); err != nil { + continue + } + if _, err := os.Stat(root + l.ip6Path); err != nil { + l.ip6Path, l.ip6Service = "", "" + } + return l, true + } + return iptLayout{}, false +} + +// NewIPTables creates an iptables manager, detecting the save-file layout and +// confirming its IPv4 restore service is enabled. An IPv6 half that has no save +// file, or whose restore service is not enabled, is left unmanaged rather than +// failing. +func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) { + ipt := new(IPTables) + ipt.rulePrefix = rulePrefix + + // Detect which packaging manages the save files on this host. + layout, ok := probeIPTLayout("") + if !ok { + return nil, errors.New(IPTablesNoSave) + } + ipt.IP4Path, ipt.IP6Path = layout.ip4Path, layout.ip6Path + + // Confirm the service that restores the rules is enabled, under whatever + // init system the host uses. + ipt.IP4Service = layout.ip4Service + if !serviceEnabled(ctx, ipt.IP4Service) { + return nil, errors.New(IPTablesNoService) + } + + // With no v6 save file there is no v6 restore service to confirm; the host is + // managed IPv4-only from here on. A save file whose restore service is not + // enabled is the same situation: writes to it would never be applied at boot, + // so IPv6 is left unmanaged rather than failing the whole backend, which still + // has a working IPv4 firewall to manage. Skip the redundant check when it is + // the same service already confirmed enabled above (the Debian layout uses one + // service for both families). + ipt.IP6Service = layout.ip6Service + if !ipt.managesIPv6() { + log.Printf("firewall: iptables found no IPv6 save file alongside %s; managing IPv4 only", ipt.IP4Path) + } else if ipt.IP6Service != ipt.IP4Service && !serviceEnabled(ctx, ipt.IP6Service) { + log.Printf("firewall: iptables service %s is not enabled; managing IPv4 only", ipt.IP6Service) + ipt.IP6Path, ipt.IP6Service = "", "" + } + + // Detect the optional ipset persistence mechanism belonging to this packaging. + // Unlike the rules save file, a missing mechanism is not fatal: address sets + // still work live, they just are not staged and do not survive a reboot + // (AddAddressSet warns when a set is added in that case). + ipt.IPSetPath, ipt.IPSetService = ipsetLayoutInstalled(ctx, layout) + + return ipt, nil +} + +// Type returns the manager type. +func (f *IPTables) Type() string { + return IPTablesType +} + +// Capabilities returns the set of features this backend can express. +func (f *IPTables) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + // IPv6 mirrors managesIPv6: with no managed ip6tables save file on this + // host there is nowhere to write an IPv6 rule of any kind. + IPv6: f.managesIPv6(), + PortPair: true, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: true, + DefaultPolicy: true, + RuleCounters: true, + AddressSets: true, + Comments: true, + Negation: true, + RejectAction: true, + FamilyWithoutAddress: true, + } +} + +// GetZone reports no zone: iptables has only policy groups, and rules are +// inserted at the top of the INPUT/OUTPUT policies. +func (f *IPTables) GetZone(ctx context.Context, iface string) (zoneName string, err error) { + return "", nil +} + +// iptParsePorts parses a multiport value list (comma-separated "p" or "lo:hi") +// into PortRange values. +func iptParsePorts(val string) ([]PortRange, error) { + return ParsePortRanges(val, ",") +} + +// unmarshalIPTablesRule decodes an iptables rulespec (e.g. an `-A CHAIN ...` +// line) into a rule. It is shared by the iptables backend and the ufw backend, +// whose before/after iptables rules files are in this format. +func unmarshalIPTablesRule(ruleSpec string, family Family) (r *Rule, err error) { + r = &Rule{ + Family: family, + } + not := false + tokens, err := shlex.Split(ruleSpec, true) + if err != nil { + return nil, err + } + + // An iptables-save line may carry a leading [pkts:bytes] counter prefix. + // Capture the counters onto the rule and strip the prefix before parsing. + if len(tokens) > 0 && strings.HasPrefix(tokens[0], "[") && strings.HasSuffix(tokens[0], "]") { + inner := strings.TrimSuffix(strings.TrimPrefix(tokens[0], "["), "]") + if pk, bs, ok := strings.Cut(inner, ":"); ok { + if n, e := strconv.ParseUint(pk, 10, 64); e == nil { + r.Packets = n + } + if n, e := strconv.ParseUint(bs, 10, 64); e == nil { + r.Bytes = n + } + } + tokens = tokens[1:] + } + + // Start at 2, the command and the chain. + i := 2 + if i >= len(tokens) { + return nil, fmt.Errorf("unexpected token length") + } + + // Check the chain. + switch tokens[1] { + case "INPUT": + r.Direction = DirInput + case "OUTPUT": + r.Direction = DirOutput + case "FORWARD": + r.Direction = DirForward + default: + return nil, fmt.Errorf("the chain is not INPUT, OUTPUT or FORWARD") + } + + // Check the command. + switch tokens[0] { + case "-A", "--append": + case "-I", "--insert": + // If insert rule has an integer rule number, increment i. + if i < len(tokens) { + _, err := strconv.Atoi(tokens[i]) + if err == nil { + i++ + } + } + case "-R", "--replace": + _, err := strconv.Atoi(tokens[i]) + if err != nil { + return nil, fmt.Errorf("the replace command requires an integer rule number") + } + i++ + default: + return nil, fmt.Errorf("unsupported command provided") + } + + // Process the rule. + for ; i < len(tokens); i++ { + switch tokens[i] { + // A leading "!" negates the match that follows it. + case "!": + not = true + // Continue so the negation is not cleared before the match token is read. + continue + case "-p", "--protocol": + // Negation is unsupported on this parameter. + if not { + return nil, fmt.Errorf("negation is defined for protocol, which our limited rule structure does not support") + } + + // Verify the protocol is specified. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid protocol parameter") + } + + // Verify the protocol value is valid. + r.Proto = GetProtocol(tokens[i]) + if r.Proto == ProtocolAny && !strings.EqualFold(tokens[i], "all") { + return nil, fmt.Errorf("invalid protocol parameter") + } + case "-s", "--source": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid source parameter") + } + + // Confirm the address parses. + _, _, err := net.ParseCIDR(tokens[i]) + ip := net.ParseIP(tokens[i]) + if err != nil && ip == nil { + return nil, fmt.Errorf("invalid source parameter") + } + + // Set the source address. + if not { + r.Source = "!" + tokens[i] + } else { + r.Source = tokens[i] + } + case "-d", "--destination": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid destination parameter") + } + + // Confirm the address parses. + _, _, err := net.ParseCIDR(tokens[i]) + ip := net.ParseIP(tokens[i]) + if err != nil && ip == nil { + return nil, fmt.Errorf("invalid destination parameter") + } + + // Set the destination address. + if not { + r.Destination = "!" + tokens[i] + } else { + r.Destination = tokens[i] + } + case "--icmp-type", "--icmpv6-type": + // A bare icmp-type match (as in `-p icmp --icmp-type echo-request`, + // without an explicit `-m icmp`), common in ufw's iptables rules files. + if not { + return nil, fmt.Errorf("a negated icmp type is not supported") + } + // The flag names the family: --icmpv6-type resolves names through the + // ICMPv6 table, where reused names (e.g. echo-request) map to different + // numbers than in ICMPv4. + v6 := tokens[i] == "--icmpv6-type" + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid icmp-type parameter") + } + n, ok := parseICMPTypeFamily(tokens[i], v6) + if !ok { + return nil, fmt.Errorf("invalid icmp type %q", tokens[i]) + } + r.ICMPType = Ptr(n) + case "--sport", "--source-port": + // A bare source-port match (as in `-p udp --sport 5353`). + if not { + return nil, fmt.Errorf("a negated source port is not supported") + } + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid sport parameter") + } + pr, perr := ParsePortRange(tokens[i]) + if perr != nil { + return nil, perr + } + if pr.Start == pr.End { + r.SourcePort = pr.Start + } else { + r.SourcePorts = []PortRange{pr} + } + case "--dport", "--destination-port": + // A bare destination-port match (as in `-p udp --dport 5353`). + if not { + return nil, fmt.Errorf("a negated port is not supported") + } + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid dport parameter") + } + pr, perr := ParsePortRange(tokens[i]) + if perr != nil { + return nil, perr + } + if pr.Start == pr.End { + r.Port = pr.Start + } else { + r.Ports = []PortRange{pr} + } + case "-i", "--in-interface": + if not { + return nil, fmt.Errorf("a negated interface match is not supported") + } + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid in-interface parameter") + } + r.InInterface = tokens[i] + case "-o", "--out-interface": + if not { + return nil, fmt.Errorf("a negated interface match is not supported") + } + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid out-interface parameter") + } + r.OutInterface = tokens[i] + case "-j", "--jump": + // Negation is unsupported on this parameter. + if not { + return nil, fmt.Errorf("negation is defined for jump, which our limited rule structure does not support") + } + + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid jump parameter") + } + + // Parse the valid options. + switch tokens[i] { + case "DROP": + r.Action = Drop + case "REJECT": + r.Action = Reject + // The Rule model carries no reject type, so a `--reject-with` detail is + // consumed and a rewrite re-emits a plain REJECT (iptables' default + // icmp-port-unreachable). Rejecting the line instead would leave common + // stock rules (RHEL's icmp-host-prohibited REJECT) foreign and + // unmanageable, which costs more than the reject-code drift. + if i+2 < len(tokens) { + if tokens[i+1] == "--reject-with" { + i += 2 + } + } + case "ACCEPT": + r.Action = Accept + case "LOG": + // A LOG target is non-terminal: a logged rule is written as a + // LOG line followed by the action line, coalesced on read. This + // line contributes only the Log flag and prefix. + r.Log = true + for i+1 < len(tokens) { + if tokens[i+1] == "--log-prefix" && i+2 < len(tokens) { + r.LogPrefix = tokens[i+2] + i += 2 + } else if tokens[i+1] == "--log-level" && i+2 < len(tokens) { + i += 2 + } else { + break + } + } + default: + return nil, fmt.Errorf("unsupported jump option: %s", tokens[i]) + } + case "-m", "--match": + // Negation is unsupported on this parameter (the set match negates + // internally, after `-m set`, so it is handled inside its case). + if not { + return nil, fmt.Errorf("negation is defined for match, which our limited rule structure does not support") + } + + // Verify options are set. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid match parameter") + } + + // Parse the valid options. + switch tokens[i] { + case "set": + // -m set [!] --match-set src|dst names an ipset in place of an + // address; the optional `!` negates it. A combined `src,dst` flag + // matches on both, which the Rule model (one set per direction) cannot + // represent, so it is rejected. + i++ + setNot := false + if i < len(tokens) && tokens[i] == "!" { + setNot = true + i++ + } + if i+2 >= len(tokens) || tokens[i] != "--match-set" { + return nil, fmt.Errorf("unsupported set match") + } + name := tokens[i+1] + dir := tokens[i+2] + i += 2 + if setNot { + name = "!" + name + } + switch dir { + case "src": + r.Source = name + case "dst": + r.Destination = name + default: + return nil, fmt.Errorf("unsupported set match direction: %s", dir) + } + case "comment": + if i+2 >= len(tokens) { + return nil, fmt.Errorf("invalid match parameter") + } + if tokens[i+1] != "--comment" { + return nil, fmt.Errorf("invalid match parameter") + } + i += 2 + // Capture the comment text. The caller strips it when it is the + // configured prefix rather than a user-supplied label. + r.Comment = tokens[i] + case "conntrack": + // Only the conntrack state option is modeled. + if i+2 >= len(tokens) || tokens[i+1] != "--ctstate" { + return nil, fmt.Errorf("unsupported conntrack match") + } + i += 2 + state, serr := ParseConnState(tokens[i]) + if serr != nil { + return nil, serr + } + r.State = state + case "state": + // Legacy state match: -m state --state NEW,ESTABLISHED. + if i+2 >= len(tokens) || tokens[i+1] != "--state" { + return nil, fmt.Errorf("unsupported state match") + } + i += 2 + state, serr := ParseConnState(tokens[i]) + if serr != nil { + return nil, serr + } + r.State = state + case "limit": + // -m limit --limit N/unit [--limit-burst B] + if i+2 >= len(tokens) || tokens[i+1] != "--limit" { + return nil, fmt.Errorf("unsupported limit match") + } + i += 2 + rate, unit, rerr := parseRateToken(tokens[i]) + if rerr != nil { + return nil, rerr + } + rl := &RateLimit{Rate: rate, Unit: unit} + if i+2 < len(tokens) && tokens[i+1] == "--limit-burst" { + b, berr := strconv.ParseUint(tokens[i+2], 10, 32) + if berr != nil { + return nil, fmt.Errorf("invalid limit burst %q", tokens[i+2]) + } + rl.Burst = uint(b) + i += 2 + } + // Legacy (xtables) iptables prints a default --limit-burst of 5 on + // every -m limit match; treat it as unset so a Burst-0 rule still + // matches on a legacy host (an explicit 5 collapses to 0 too). + if rl.Burst == 5 { + rl.Burst = 0 + } + r.RateLimit = rl + case "connlimit": + // -m connlimit --connlimit-above N [--connlimit-mask M] + if i+2 >= len(tokens) || tokens[i+1] != "--connlimit-above" { + return nil, fmt.Errorf("unsupported connlimit match") + } + i += 2 + n, nerr := strconv.ParseUint(tokens[i], 10, 32) + if nerr != nil { + return nil, fmt.Errorf("invalid connlimit %q", tokens[i]) + } + // The default mask (32/128) counts per source; an explicit mask + // of 0 counts globally. + cl := &ConnLimit{Count: uint(n), PerSource: true} + if i+2 < len(tokens) && tokens[i+1] == "--connlimit-mask" { + if tokens[i+2] == "0" { + cl.PerSource = false + } + i += 2 + } + // iptables-save always appends the counting key (--connlimit-saddr + // by default, or --connlimit-daddr) after the match; consume it so + // the trailing flag does not fail the parse and drop the whole rule. + if i+1 < len(tokens) && (tokens[i+1] == "--connlimit-saddr" || tokens[i+1] == "--connlimit-daddr") { + i++ + } + r.ConnLimit = cl + case "icmp", "icmp6": + // -m icmp --icmp-type N / -m icmp6 --icmpv6-type N. The type + // qualifier is optional; a bare match just selects the module. + v6 := tokens[i] == "icmp6" + typeFlag := "--icmp-type" + if v6 { + typeFlag = "--icmpv6-type" + } + if i+2 < len(tokens) && tokens[i+1] == typeFlag { + i += 2 + // iptables-save spells a type-with-code as `type/code` (e.g. + // `3/1`); the Rule model carries only the type, so drop a trailing + // `/code` before resolving rather than failing the whole rule. + typeTok := tokens[i] + if slash := strings.IndexByte(typeTok, '/'); slash >= 0 { + typeTok = typeTok[:slash] + } + n, ok := parseICMPTypeFamily(typeTok, v6) + if !ok { + return nil, fmt.Errorf("invalid icmp type %q", tokens[i]) + } + r.ICMPType = Ptr(n) + } + case "multiport": + // -m multiport --dports/--sports 80,443,1000:2000. `--ports`/`--port` + // means source OR destination port, which the model cannot hold — + // mapping it onto one side would silently drop the other half on a + // re-marshal — so such a line stays foreign. + if i+2 >= len(tokens) { + return nil, fmt.Errorf("invalid multiport match") + } + switch tokens[i+1] { + case "--dports", "--dport", "--sports", "--sport": + default: + return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1]) + } + src := strings.HasPrefix(tokens[i+1], "--s") + i += 2 + specs, perr := iptParsePorts(tokens[i]) + if perr != nil { + return nil, perr + } + if src { + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.SourcePort = specs[0].Start + } else { + r.SourcePorts = specs + } + } else { + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.Port = specs[0].Start + } else { + r.Ports = specs + } + } + case "tcp": + // Reject an unknown protocol token. + if r.Proto == UDP { + return nil, fmt.Errorf("specifying TCP options for UDP") + } + + // Verify options are set. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid match parameter") + } + + // Parse options. + tcpTokenLoop: + for ; i < len(tokens); i++ { + switch tokens[i] { + case "!", "--syn", "--tcp-option": + return nil, fmt.Errorf("invalid match parameter") + case "--source-port", "--sport": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid match parameter") + } + + // Parse port (iptables-save renders a contiguous range as lo:hi). + pr, err := ParsePortRange(tokens[i]) + if err != nil { + return nil, fmt.Errorf("the port argument %s is invalid", tokens[i]) + } + if pr.Start == pr.End { + r.SourcePort = pr.Start + } else { + r.SourcePorts = []PortRange{pr} + } + case "--destination-port", "--dport": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid match parameter") + } + + // Parse port (iptables-save renders a contiguous range as lo:hi). + pr, err := ParsePortRange(tokens[i]) + if err != nil { + return nil, fmt.Errorf("the port argument %s is invalid", tokens[i]) + } + if pr.Start == pr.End { + r.Port = pr.Start + } else { + r.Ports = []PortRange{pr} + } + default: + i-- + break tcpTokenLoop + } + } + case "udp", "sctp": + // SCTP carries ports like UDP and iptables-save spells its port + // match module `-m sctp`, so it shares this branch. + // Reject an unknown protocol token. + if r.Proto == TCP { + return nil, fmt.Errorf("specifying UDP options for TCP") + } + + // Verify options are set. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid match parameter") + } + + // Parse options. + udpTokenLoop: + for ; i < len(tokens); i++ { + switch tokens[i] { + case "!": + // A negated match cannot be represented. + return nil, fmt.Errorf("invalid match parameter") + case "--source-port", "--sport": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid match parameter") + } + + // Parse port (iptables-save renders a contiguous range as lo:hi). + pr, err := ParsePortRange(tokens[i]) + if err != nil { + return nil, fmt.Errorf("the port argument %s is invalid", tokens[i]) + } + if pr.Start == pr.End { + r.SourcePort = pr.Start + } else { + r.SourcePorts = []PortRange{pr} + } + case "--destination-port", "--dport": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid match parameter") + } + + // Parse port (iptables-save renders a contiguous range as lo:hi). + pr, err := ParsePortRange(tokens[i]) + if err != nil { + return nil, fmt.Errorf("the port argument %s is invalid", tokens[i]) + } + if pr.Start == pr.End { + r.Port = pr.Start + } else { + r.Ports = []PortRange{pr} + } + default: + i-- + break udpTokenLoop + } + } + default: + return nil, fmt.Errorf("unsupported match option: %s", tokens[i]) + } + default: + return nil, fmt.Errorf("unsupported option: %s", tokens[i]) + } + + // The token consumed any pending negation; clear it for the next one. + not = false + } + + // If no action provided, error — unless this is a LOG-only line (the log + // half of a logged rule), which carries the Log flag but no terminal action. + if r.Action == ActionInvalid && !r.Log { + return nil, fmt.Errorf("no valid action was provided") + } + return +} + +// parseLiveRules decodes counter-annotated `iptables-save -c` output into the +// rules the kernel's filter chains hold. iptables writes a rule as the same +// rulespec it stores, so there is no product framing to undo here — the rulespec +// parser takes each row as it stands, and rejects the chains this backend does +// not report. A LOG line and its action line still pair up into the one logged +// rule the save file reports (see decodeLiveRows). +func (f *IPTables) parseLiveRules(out []string, fam Family) []*Rule { + return decodeLiveRows(out, func(row liveRow) (*Rule, bool) { + r, err := unmarshalIPTablesRule(row.line, fam) + if err != nil { + return nil, false + } + return r, true + }) +} + +// mergeLiveCounters copies the kernel's packet/byte counters onto the +// file-parsed rules. The save files carry no counts, so a file edit not yet +// activated by Reload leaves that rule's counters zero. +func (f *IPTables) mergeLiveCounters(ctx context.Context, rules []*Rule, fam Family) { + // Each save file holds one family, so its rules are the ones that family's + // live ruleset can account for. + targets := countableRules(rules, fam) + if len(targets) == 0 { + return + } + applyLiveCounters(targets, f.parseLiveRules(liveSaveLines(ctx, fam), fam)) +} + +// parseNATTarget parses an iptables NAT target ("addr", "addr:port" or +// "[v6]:port") into its address and port. +func (f *IPTables) parseNATTarget(tok string) (addr string, port uint16) { + if strings.HasPrefix(tok, "[") { + if end := strings.Index(tok, "]"); end >= 0 { + addr = tok[1:end] + rest := tok[end+1:] + if strings.HasPrefix(rest, ":") { + if p, err := strconv.ParseUint(rest[1:], 10, 16); err == nil { + port = uint16(p) + } + } + return addr, port + } + } + if strings.Count(tok, ":") == 1 { + host, ps, _ := strings.Cut(tok, ":") + if p, err := strconv.ParseUint(ps, 10, 16); err == nil { + return host, uint16(p) + } + } + return tok, 0 +} + +// UnmarshalNATRule decodes one iptables-save nat line (-A PREROUTING / -A +// POSTROUTING ...) into a NATRule. A line the model cannot hold faithfully — a +// negated interface/protocol/port match, an unknown protocol, an unsupported +// jump, or another chain — is rejected so the raw line stays foreign and is +// preserved verbatim; an OUTPUT-chain DNAT, for example, has no distinct model +// here and would otherwise be relocated to PREROUTING on Restore. +func (f *IPTables) UnmarshalNATRule(spec string, family Family) (*NATRule, error) { + tokens, err := shlex.Split(spec, true) + if err != nil { + return nil, err + } + + // An iptables-save line may carry a leading [pkts:bytes] counter prefix + // (iptables-save -c). NATRule has no counter fields, so just strip it before + // parsing — mirroring the filter parser so a counter-annotated save file's + // NAT rules are not silently dropped. + if len(tokens) > 0 && strings.HasPrefix(tokens[0], "[") && strings.HasSuffix(tokens[0], "]") { + tokens = tokens[1:] + } + if len(tokens) < 2 { + return nil, fmt.Errorf("unexpected token length") + } + + r := &NATRule{Family: family} + switch tokens[1] { + case "PREROUTING", "POSTROUTING": + default: + // The NATRule model derives its chain from Kind (DNAT/Redirect => PREROUTING, + // SNAT/Masquerade => POSTROUTING) and has no direction field, so an OUTPUT-chain + // nat rule (locally-generated DNAT) cannot be represented distinctly — surfacing + // it would make MarshalNATRule relocate it to PREROUTING on Restore. Treat the + // OUTPUT chain (and any other) as foreign: skip it on read so it is left in place + // verbatim rather than moved (see managedNATChain). + return nil, fmt.Errorf("not a managed nat chain: %s", tokens[1]) + } + + i := 2 + switch tokens[0] { + case "-A", "--append": + case "-I", "--insert": + if i < len(tokens) { + if _, err := strconv.Atoi(tokens[i]); err == nil { + i++ + } + } + default: + return nil, fmt.Errorf("unsupported command provided") + } + + not := false + for ; i < len(tokens); i++ { + switch tokens[i] { + case "!": + not = true + continue + case "-s", "--source": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid source parameter") + } + if not { + r.Source = "!" + tokens[i] + } else { + r.Source = tokens[i] + } + case "-d", "--destination": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid destination parameter") + } + if not { + r.Destination = "!" + tokens[i] + } else { + r.Destination = tokens[i] + } + case "-i", "--in-interface", "-o", "--out-interface": + // Interface, protocol and port carry no negated form in the model, so + // a `!` here must reject the line — reading `! -o docker0` as a plain + // interface match would invert the rule's meaning (Docker's stock + // masquerade rule is exactly this shape). + if not { + return nil, fmt.Errorf("negated interface match is not modeled") + } + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid interface parameter") + } + r.Interface = tokens[i] + case "-p", "--protocol": + if not { + return nil, fmt.Errorf("negated protocol match is not modeled") + } + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid protocol parameter") + } + proto := GetProtocol(tokens[i]) + if proto == ProtocolAny { + // An unknown protocol token must not silently widen to a + // match-every-protocol rule a Restore would re-marshal without -p. + return nil, fmt.Errorf("unsupported protocol: %s", tokens[i]) + } + r.Proto = proto + case "--dport", "--destination-port": + if not { + return nil, fmt.Errorf("negated port match is not modeled") + } + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid dport parameter") + } + pr, perr := ParsePortRange(tokens[i]) + if perr != nil { + return nil, perr + } + if pr.Start == pr.End { + r.Port = pr.Start + } else { + r.Ports = []PortRange{pr} + } + case "-m", "--match": + if not { + return nil, fmt.Errorf("negated match is not modeled") + } + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid match parameter") + } + switch tokens[i] { + case "set": + // -m set [!] --match-set src|dst names an ipset in place of an + // address (see the filter parser). + i++ + setNot := false + if i < len(tokens) && tokens[i] == "!" { + setNot = true + i++ + } + if i+2 >= len(tokens) || tokens[i] != "--match-set" { + return nil, fmt.Errorf("unsupported set match") + } + name := tokens[i+1] + dir := tokens[i+2] + i += 2 + if setNot { + name = "!" + name + } + switch dir { + case "src": + r.Source = name + case "dst": + r.Destination = name + default: + return nil, fmt.Errorf("unsupported set match direction: %s", dir) + } + case "comment": + if i+2 >= len(tokens) || tokens[i+1] != "--comment" { + return nil, fmt.Errorf("invalid match parameter") + } + i += 2 + // A NAT rule carries no user comment, only the prefix tag; its + // presence marks the rule as one this library tagged. + if _, hasPrefix := prefixedComment(f.rulePrefix, tokens[i]); hasPrefix { + r.HasPrefix = true + } + case "tcp", "udp", "sctp": + if i+2 < len(tokens) && (tokens[i+1] == "--dport" || tokens[i+1] == "--destination-port") { + i += 2 + pr, perr := ParsePortRange(tokens[i]) + if perr != nil { + return nil, perr + } + if pr.Start == pr.End { + r.Port = pr.Start + } else { + r.Ports = []PortRange{pr} + } + } + case "multiport": + if i+2 >= len(tokens) { + return nil, fmt.Errorf("invalid multiport match") + } + switch tokens[i+1] { + case "--dports", "--dport": + default: + // `--ports`/`--port` means source OR destination port; mapping it + // onto the destination fields would silently drop the source half + // on a re-marshal, so the line stays foreign. + return nil, fmt.Errorf("unsupported multiport option: %s", tokens[i+1]) + } + i += 2 + specs, perr := iptParsePorts(tokens[i]) + if perr != nil { + return nil, perr + } + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.Port = specs[0].Start + } else { + r.Ports = specs + } + default: + return nil, fmt.Errorf("unsupported match option: %s", tokens[i]) + } + case "-j", "--jump": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid jump parameter") + } + switch tokens[i] { + case "DNAT": + r.Kind = DNAT + if i+2 < len(tokens) && tokens[i+1] == "--to-destination" { + i += 2 + r.ToAddress, r.ToPort = f.parseNATTarget(tokens[i]) + } + case "REDIRECT": + r.Kind = Redirect + if i+2 < len(tokens) && tokens[i+1] == "--to-ports" { + i += 2 + p, perr := strconv.ParseUint(tokens[i], 10, 16) + if perr != nil { + return nil, fmt.Errorf("invalid redirect port %q", tokens[i]) + } + r.ToPort = uint16(p) + } + case "SNAT": + r.Kind = SNAT + if i+2 < len(tokens) && tokens[i+1] == "--to-source" { + i += 2 + r.ToAddress, r.ToPort = f.parseNATTarget(tokens[i]) + } + case "MASQUERADE": + r.Kind = Masquerade + default: + return nil, fmt.Errorf("unsupported nat target: %s", tokens[i]) + } + default: + return nil, fmt.Errorf("unsupported option: %s", tokens[i]) + } + not = false + } + + if r.Kind == NATInvalid { + return nil, fmt.Errorf("no nat action was provided") + } + if r.Family == FamilyAny { + r.Family = r.impliedFamily() + } + return r, nil +} + +// UnmarshalRule decodes an iptables rulespec into a firewall rule. +func (f *IPTables) UnmarshalRule(ruleSpec string, family Family) (*Rule, error) { + r, err := unmarshalIPTablesRule(ruleSpec, family) + if err != nil { + return nil, err + } + // The shared parser is prefix-agnostic; strip this backend's configured + // prefix so only the user-facing comment surfaces, and record whether the + // prefix was present so callers can tell our rules from foreign ones. The + // comment is not part of rule identity, so this does not affect dedup or + // removal comparisons. An empty prefix gives us no namespace, so no rule + // reports HasPrefix. + text, hasPrefix := prefixedComment(f.rulePrefix, r.Comment) + r.Comment = text + r.HasPrefix = hasPrefix + return r, nil +} + +// iptSameMatch reports whether two rules have identical match fields ignoring +// their action and logging flags. It is used to pair a LOG line with the action +// line that follows it. +func iptSameMatch(a, b *Rule) bool { + ac, bc := *a, *b + ac.Log, bc.Log = false, false + ac.LogPrefix, bc.LogPrefix = "", "" + ac.Action, bc.Action = Accept, Accept + return ac.EqualBase(&bc, true) +} + +// logPartner reports whether cur and next are the two physical lines that +// iptables needs to express one logical "log and act" rule. The library models +// logging as a flag on a rule that also has a terminal action (e.g. drop and +// log inbound TCP :22), but iptables cannot: its LOG target is non-terminal — +// the packet keeps traversing the chain after being logged — so the rule must +// be written as two lines with identical match fields, a LOG line followed by +// the action line: +// +// iptables -A INPUT -s 10.0.0.0/8 -p tcp --dport 22 -j LOG --log-prefix "fw: " +// iptables -A INPUT -s 10.0.0.0/8 -p tcp --dport 22 -j DROP +// +// cur is the standalone LOG line (Log set, no terminal action) and next is its +// action partner: same match fields ignoring the log flags and action, which +// iptSameMatch verifies. When it matches, callers fold the pair back into the +// single logged rule GetRules reports (see mergeLogPair). next may be nil when +// cur is the last rule in the sequence, in which case the LOG line is an orphan +// and this returns false. Callers must also confirm the two lines are physically +// adjacent (no line between them): a LOG and an action separated by an unmodeled +// foreign line are not one rule, and pairing them would synthesize a logical +// rule no removal path could locate. It is the shared predicate behind +// coalesceLoggedRules (native iptables-save reads) and hookScript.scanGroups +// (CSF/APF hook reads), so the pairing behaves identically in both. +func logPartner(cur, next *Rule) bool { + return cur != nil && cur.Action == ActionInvalid && cur.Log && + next != nil && next.Action != ActionInvalid && iptSameMatch(cur, next) +} + +// mergeLogPair folds a standalone LOG line's prefix into its action partner, +// producing the single logical rule GetRules reports for a logged rule. The +// action line supplies the terminal action and match fields; only Log and +// LogPrefix are carried over from the LOG line. Pass a pair logPartner accepted. +func mergeLogPair(logLine, action *Rule) *Rule { + merged := *action + merged.Log = true + merged.LogPrefix = logLine.LogPrefix + return &merged +} + +// ruleLineBody strips an optional leading [pkts:bytes] counter token from a +// trimmed iptables-save line and returns the remaining rule body. iptables-save +// -c annotates each rule with counters; the library never emits them, but the +// file-rewrite paths must still recognise a counter-prefixed line as a rule when +// operating on a pre-existing save file (matching the read parser, which strips +// the same prefix). A line without a counter is returned unchanged. +func (f *IPTables) ruleLineBody(line string) string { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") { + if i := strings.IndexByte(line, ']'); i >= 0 { + return strings.TrimSpace(line[i+1:]) + } + } + return line +} + +// --- save-file scanning ------------------------------------------------------ + +// iptGroup is one logical span of an iptables-save file: a rule line together +// with the rule it encodes — a LOG line and the action line under it count as +// one group — or any other line on its own. It is the iptables counterpart of +// the hook's hookGroup, and every read and rewrite path streams the file through +// it, so table scoping, LOG-pair coalescing and the per-chain numbering GetRules +// reports are each defined in exactly one place. +type iptGroup struct { + // raw preserves the original lines verbatim, so a rewrite copies user + // formatting through and a removal drops a logged rule's two lines together. + raw []string + // table is the table the group's lines sit in ("filter", "nat", ...), or "" + // outside any table. A table header belongs to the table it opens and a + // COMMIT to the table it closes. + table string + // chain is the chain a rule line targets, "" for every other line. + chain string + // rule is the filter rule the group encodes, nil when it encodes none: a + // line outside *filter, one the model cannot hold, one a container runtime + // owns, an orphan LOG line, or a line that is not a rule at all. + rule *Rule + // nat is the nat rule the group encodes, on the same terms as rule. + nat *NATRule + // number is the 1-based position within its chain that rule or nat occupies, + // mirroring the Number GetRules and GetNATRules report, or 0 when the group + // begins no logical rule. + number int + // commit marks the group as its table's COMMIT line. + commit bool +} + +// chainOf returns the chain named by an iptables-save rule body such as +// "-A FORWARD -j ACCEPT" (the token after the -A/-I/-R command), or "" when the +// body has no chain token. It lets the file-rewrite paths tell an INPUT/OUTPUT +// rule the library manages from a rule in a chain it does not model. +func (f *IPTables) chainOf(body string) string { + fields := strings.Fields(body) + if len(fields) >= 2 { + return fields[1] + } + return "" +} + +// ruleLineChain returns the chain an iptables-save rule line targets, or "" when +// the line is not a rule line. It gates chainOf on the commands iptables-save +// emits (-A) and those a hand-edited file may carry (-I, -R), so a chain +// declaration such as ":INPUT ACCEPT [0:0]" is not read as a rule in a chain +// named ACCEPT. Every ignorable line — blank, comment, table header, chain +// declaration, COMMIT — fails the same test, which is why the scanner needs no +// separate ignorable-line check. +func (f *IPTables) ruleLineChain(body string) string { + switch { + case strings.HasPrefix(body, "-A "), strings.HasPrefix(body, "-I "), strings.HasPrefix(body, "-R "): + return f.chainOf(body) + } + return "" +} + +// scanSaveGroups streams an iptables-save file to fn as logical groups in file +// order, emitting every line exactly once so a caller can rewrite the file in a +// single pass. A rule line is parsed only in the table that models it — *filter +// into a Rule, *nat into a NATRule — so an INPUT rule sitting in *nat or *mangle +// is never mistaken for a filter rule. +// +// A LOG line pairs with the action line PHYSICALLY under it and nothing else: +// iptables writes a logged rule as two lines, and a pair separated by any other +// line (a comment, a rule the model cannot hold, a rule in another chain) is not +// one rule. Pairing across such a line would report through GetRules a logged +// rule that no removal could ever locate, so the scan treats adjacency as the +// whole test — the same rule hookScript.scanGroups applies in the hook. An +// orphan LOG line is invisible to GetRules and so begins no logical rule; its +// group still streams through with rule left nil. +// +// A nil fd scans as an empty file. An error from fn stops the scan. +func (f *IPTables) scanSaveGroups(fd *os.File, family Family, fn func(g iptGroup) error) error { + if fd == nil { + return nil + } + table := "" + // counts holds each chain's logical-rule count so far, keyed by table and + // chain because the same built-in chain name exists in several tables. + counts := map[string]int{} + next := func(t, chain string) int { + k := t + " " + chain + counts[k]++ + return counts[k] + } + // held buffers a parsed LOG line until the following line decides whether it + // is that line's action partner; emitHeld flushes it as the orphan it turned + // out to be, with its rule cleared. + var held *iptGroup + emitHeld := func() error { + if held == nil { + return nil + } + g := *held + held = nil + g.rule = nil + return fn(g) + } + + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + raw := scanner.Text() + line := strings.TrimSpace(raw) + + // A table header opens its table and a COMMIT closes it. Both end any + // pending LOG pairing, since a partner must sit on the very next line. + if strings.HasPrefix(line, "*") || line == "COMMIT" { + if err := emitHeld(); err != nil { + return err + } + g := iptGroup{raw: []string{raw}, table: table} + if line == "COMMIT" { + g.commit = true + table = "" + } else { + table = strings.TrimPrefix(line, "*") + g.table = table + } + if err := fn(g); err != nil { + return err + } + continue + } + + g := iptGroup{raw: []string{raw}, table: table, chain: f.ruleLineChain(f.ruleLineBody(line))} + + switch { + case g.chain == "": + // Not a rule line; it passes through and breaks any pending pairing. + case table == "filter": + rule, err := f.UnmarshalRule(line, family) + // A line the model cannot hold, and one a container runtime owns, are + // both invisible to GetRules: each keeps its physical slot but begins no + // logical rule, so it takes no Number and no LOG line pairs across it. + if err != nil || rule.isContainerRuntime() { + break + } + // Fold a held LOG line together with the action line directly under it + // into the one logged rule they encode, numbered at the LOG line. + if held != nil && logPartner(held.rule, rule) { + pair := *held + held = nil + pair.raw = append(pair.raw, raw) + pair.rule = mergeLogPair(pair.rule, rule) + pair.number = next(pair.table, pair.chain) + if err := fn(pair); err != nil { + return err + } + continue + } + // Not a partner, so any held LOG line is an orphan; flush it before + // this line. + if err := emitHeld(); err != nil { + return err + } + // Buffer a bare LOG line (Log set, no terminal action) against the next + // line; every other rule is complete on its own. + if rule.Action == ActionInvalid && rule.Log { + g.rule = rule + held = &g + continue + } + g.rule = rule + g.number = next(table, g.chain) + case table == "nat": + nr, err := f.UnmarshalNATRule(line, family) + // As in *filter, a line the model cannot hold and a container runtime's + // own translation are both invisible to GetNATRules and take no Number. + if err != nil || nr.isContainerRuntime() { + break + } + g.nat = nr + g.number = next(table, g.chain) + } + + if err := emitHeld(); err != nil { + return err + } + if err := fn(g); err != nil { + return err + } + } + if err := scanner.Err(); err != nil { + return err + } + return emitHeld() +} + +// scanSaveFile opens path and streams it through scanSaveGroups, for the read +// paths that have no rewrite to stage. A save file this host manages is expected +// to exist, so an open error is reported rather than scanned as empty. +func (f *IPTables) scanSaveFile(path string, family Family, fn func(g iptGroup) error) error { + fd, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = fd.Close() }() + return f.scanSaveGroups(fd, family, fn) +} + +// parseFilterFile reads a family's iptables-save file and returns its filter +// rules as logical rules, coalescing each LOG line with the action line that +// follows it. Lines the model cannot hold — nat rules, custom chains, unmodeled +// foreign matches — are skipped by the scan (see scanSaveGroups). +func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) { + // The scan already scopes to the table that models each rule and drops the + // lines GetRules cannot report, so every filter group carrying a rule is one + // to return. GetRules assigns Number once both save files are read; the other + // callers use the result only for dedup and never read Number. + var out []*Rule + err := f.scanSaveFile(path, family, func(g iptGroup) error { + if g.table == "filter" && g.rule != nil { + out = append(out, g.rule) + } + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// GetRules returns the existing filter rules from the zone. +func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + // Each save-file line is its own rule: iptables pins one family (by which file + // holds it), one transport and one direction (by chain) per line, so nothing here + // spans two of anything and nothing is collapsed. + v4, err := f.parseFilterFile(f.IP4Path, IPv4) + if err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err) + } + // An IPv4-only host has no v6 file to read; it reports IPv4 rows alone. + var v6 []*Rule + if f.managesIPv6() { + if v6, err = f.parseFilterFile(f.IP6Path, IPv6); err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err) + } + } + + // Number each family's chains independently: the two families are separate + // rulesets in separate save files, so an IPv6 rule's InsertRule/MoveRule position + // counts only the ip6tables chain it lives in. Numbering the concatenation instead + // would offset every IPv6 rule by the IPv4 chain's length. + numberByDirection(v4) + numberByDirection(v6) + + // The save files carry no packet/byte counters — the kernel does — so merge + // them from the live ruleset (RuleCounters). + f.mergeLiveCounters(ctx, v4, IPv4) + f.mergeLiveCounters(ctx, v6, IPv6) + + rules = append(rules, v4...) + rules = append(rules, v6...) + return +} + +// checkFamilyManaged rejects a write pinned to a family this host does not +// manage. Only IPv6 can be unmanaged, and only on an IPv4-only host; the error +// wraps ErrUnsupported so a caller can tell "this host cannot express it" from a +// malformed rule. A FamilyAny write is not rejected: it narrows to the IPv4 file, +// matching how the other backends behave when their IPv6 handling is off. +func (f *IPTables) checkFamilyManaged(fam Family) error { + if fam == IPv6 && !f.managesIPv6() { + return fmt.Errorf("iptables does not manage IPv6 on this host, so it cannot manage IPv6 rules: %w", ErrUnsupported) + } + return nil +} + +// editRuleFiles applies one logical-rule edit to each family save file the +// rule touches. The rule is fanned out once into its concrete cells — the +// DirAny direction split times the TCPUDP protocol split, each cell one +// save-file logical rule — and op rewrites each file's parsed groups covering +// every cell in a single pass. Each changed file is staged first and committed +// only once every file has staged, so a rule spanning families, directions or +// protocols is never left half-applied by a failure part-way through. +func (f *IPTables) editRuleFiles(r *Rule, op func(groups []iptGroup, cells []*Rule) ([]iptGroup, bool, error)) error { + // Fan the merged axes out into concrete cells. Only the direction decides + // which chain a cell's lines land in; a TCPUDP split lands a tcp and a udp + // line at the same spot, and the family split selects whole files below. + var cells []*Rule + for _, sub := range expandDirections(r) { + cells = append(cells, expandProtocols(sub)...) + } + + // Resolve the family files, letting an ICMP/ICMPv6 protocol pin the family: + // `-p icmp` belongs only in the IPv4 file and `-p icmpv6` only in the IPv6 + // file. An IPv4-only host contributes no v6 path, so a FamilyAny rule + // narrows to the IPv4 file and a concrete-IPv6 rule resolves to no file at + // all — the write entry points reject that shape up front through + // checkFamilyManaged, leaving removals here as no-ops. + family := r.impliedFamily() + var paths []string + if family == IPv4 || family == FamilyAny { + paths = append(paths, f.IP4Path) + } + if (family == IPv6 || family == FamilyAny) && f.managesIPv6() { + paths = append(paths, f.IP6Path) + } + + // Rewrite and stage every file first, committing nothing until all have + // staged; a failure part-way discards the staged temp files. + var staged []*atomicFile + abort := func() { + for _, s := range staged { + s.Abort() + } + } + for _, path := range paths { + var groups []iptGroup + if err := f.scanSaveFile(path, FamilyAny, func(g iptGroup) error { + groups = append(groups, g) + return nil + }); err != nil { + abort() + return err + } + out, changed, err := op(groups, cells) + if err != nil { + abort() + return err + } + // An unchanged file needs no rewrite. + if !changed { + continue + } + af, err := newAtomicFile(path, 0644) + if err != nil { + abort() + return err + } + for _, g := range out { + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + } + staged = append(staged, af) + } + + // Commit each staged file into place, preserving its mode and ownership. + for _, s := range staged { + if err := s.Commit(); err != nil { + return fmt.Errorf("failed to move new firewall rules into place: %s", err) + } + } + return nil +} + +// addrArgs encodes a source or destination match. dir is "src" or "dst". An +// IP/CIDR uses `-s`/`-d`; a non-address token names an ipset, matched with +// `-m set --match-set `. A leading "!" negation is emitted before the +// match in both cases. +func (f *IPTables) addrArgs(addr, dir string) []string { + neg, bare := splitAddrNeg(addr) + if isSetRef(addr) { + // The set match negates internally: `-m set ! --match-set name dir`. + out := []string{"-m", "set"} + if neg { + out = append(out, "!") + } + return append(out, "--match-set", bare, dir) + } + // An address negates with a leading `!`: `! -s addr`. + var out []string + if neg { + out = append(out, "!") + } + flag := "-s" + if dir == "dst" { + flag = "-d" + } + return append(out, flag, bare) +} + +// iptMultiportValue renders port specs for `-m multiport --dports`, using a +// colon for ranges (e.g. "80,443,1000:2000"). The specs are canonicalized +// (sorted, with contiguous/overlapping ranges merged) so that two rules the model +// considers Equal — port-set order and coalescing are not part of rule identity — +// always render to the same string. Backends that match on the exact marshalled +// line (the CSF/APF hook script) rely on this to stay idempotent. +func iptMultiportValue(specs []PortRange) string { + specs = coalescePortRanges(specs) + parts := make([]string, len(specs)) + for i, pr := range specs { + if pr.Start == pr.End { + parts[i] = strconv.FormatUint(uint64(pr.Start), 10) + } else { + parts[i] = fmt.Sprintf("%d:%d", pr.Start, pr.End) + } + } + return strings.Join(parts, ",") +} + +// quoteCommentToken double-quotes v for a comment/log-prefix token in an +// iptables-save-format rule line, escaping only backslash and double-quote so it +// round-trips through the shlex.Split reader (strconv.Quote is unusable: its +// \t/\n/\uXXXX escapes are not un-escaped by shlex). A literal newline or +// carriage return is rejected outright, since it would split the one-line rule. +func (f *IPTables) quoteCommentToken(v string) (string, error) { + if strings.ContainsAny(v, "\n\r") { + return "", fmt.Errorf("a comment cannot contain a newline") + } + var b strings.Builder + b.WriteByte('"') + for _, r := range v { + if r == '\\' || r == '"' { + b.WriteByte('\\') + } + b.WriteRune(r) + } + b.WriteByte('"') + return b.String(), nil +} + +// stateValue renders a conntrack state set as an upper-case comma list (e.g. +// "NEW,ESTABLISHED"). +func (f *IPTables) stateValue(s ConnState) string { + names := s.Strings() + for i, n := range names { + names[i] = strings.ToUpper(n) + } + return strings.Join(names, ",") +} + +// validateRule reports whether iptables can express the filter rule, applying +// the universal Rule.validate and then the iptables-specific shape constraints, +// so marshalMatches encodes a rule already known to be expressible. +func (f *IPTables) validateRule(r *Rule) error { + if err := r.validate(); err != nil { + return err + } + // A TCPUDP rule has no single-line iptables form; it must be fanned out into a + // tcp row and a udp row before reaching this row-level marshaller. Reaching here + // with TCPUDP means that fan-out was skipped. + if err := r.CheckExpandedProtocol(); err != nil { + return err + } + return nil +} + +// marshalMatches builds the iptables-save match tokens for a rule (everything +// up to but not including the `-j `), including any rate/connection +// limit and the identifying comment. MarshalRule and the LOG-line encoder share +// it so a logged rule's two lines carry identical match tokens. It is a pure +// encoder: callers run validateRule on the fanned-out cell first. +func (f *IPTables) marshalMatches(r *Rule) ([]string, error) { + // Start with the APPEND command and the chain (INPUT, OUTPUT or FORWARD). + parts := []string{} + switch r.Direction { + case DirOutput: + parts = append(parts, "-A", "OUTPUT") + case DirForward: + parts = append(parts, "-A", "FORWARD") + default: + parts = append(parts, "-A", "INPUT") + } + + // Add source and destination. A non-address token names an ipset, matched with + // `-m set --match-set` rather than `-s`/`-d`. + if r.Source != "" { + parts = append(parts, f.addrArgs(r.Source, "src")...) + } + if r.Destination != "" { + parts = append(parts, f.addrArgs(r.Destination, "dst")...) + } + + // Interface match. `-i` is only valid on INPUT and `-o` only on OUTPUT; the + // FORWARD chain sees both an ingress and an egress interface, so it accepts + // either. validateRule has already rejected a wrong-side pairing. + if r.InInterface != "" { + parts = append(parts, "-i", r.InInterface) + } + if r.OutInterface != "" { + parts = append(parts, "-o", r.OutInterface) + } + + // Append protocol. + if r.Proto != ProtocolAny { + parts = append(parts, "-p", r.Proto.String()) + } + + // An ICMP type match uses the icmp/icmp6 match module. + if r.Proto.IsICMP() && r.ICMPType != nil { + if r.Proto == ICMPv6 { + parts = append(parts, "-m", "icmp6", "--icmpv6-type", strconv.Itoa(int(*r.ICMPType))) + } else { + parts = append(parts, "-m", "icmp", "--icmp-type", strconv.Itoa(int(*r.ICMPType))) + } + } + + srcSpecs := r.SourcePortSpecs() + dstSpecs := r.PortSpecs() + + // If source port(s) defined, add them. A concrete protocol is guaranteed above. + if len(srcSpecs) == 1 && srcSpecs[0].Start == srcSpecs[0].End { + parts = append(parts, "-m", r.Proto.String(), "--sport", strconv.FormatUint(uint64(srcSpecs[0].Start), 10)) + } else if len(srcSpecs) > 0 { + parts = append(parts, "-m", "multiport", "--sports", iptMultiportValue(srcSpecs)) + } + + // If destination port(s) defined, add them. + if len(dstSpecs) == 1 && dstSpecs[0].Start == dstSpecs[0].End { + parts = append(parts, "-m", r.Proto.String(), "--dport", strconv.FormatUint(uint64(dstSpecs[0].Start), 10)) + } else if len(dstSpecs) > 0 { + parts = append(parts, "-m", "multiport", "--dports", iptMultiportValue(dstSpecs)) + } + + // Connection-tracking state match. + if r.State != 0 { + parts = append(parts, "-m", "conntrack", "--ctstate", f.stateValue(r.State)) + } + + // Rate limit: `-m limit` matches only while under the configured rate. + if r.RateLimit != nil { + parts = append(parts, "-m", "limit", "--limit", r.RateLimit.String()) + if r.RateLimit.Burst > 0 { + parts = append(parts, "--limit-burst", strconv.FormatUint(uint64(r.RateLimit.Burst), 10)) + } + } + + // Connection limit: `-m connlimit` matches while the tracked count is over + // the limit. The default mask counts per source; a mask of 0 counts globally. + if r.ConnLimit != nil { + parts = append(parts, "-m", "connlimit", "--connlimit-above", strconv.FormatUint(uint64(r.ConnLimit.Count), 10)) + if !r.ConnLimit.PerSource { + parts = append(parts, "--connlimit-mask", "0") + } + } + + // Attach a comment. A user-supplied Comment is carried alongside the + // configured prefix (prefix + " " + comment) so rules this library creates + // stay identifiable; with no user comment the prefix alone tags the rule. + // The comment is not part of the rule identity, so it is ignored when + // comparing rules. + comment := combineComment(f.rulePrefix, r.Comment) + if comment != "" { + quoted, err := f.quoteCommentToken(comment) + if err != nil { + return nil, err + } + parts = append(parts, "-m", "comment", "--comment", quoted) + } + + return parts, nil +} + +// MarshalRule encodes a rule as a single iptables-save rulespec ending in its +// action target. +func (f *IPTables) MarshalRule(r *Rule) (string, error) { + parts, err := f.marshalMatches(r) + if err != nil { + return "", err + } + parts = append(parts, "-j", strings.ToUpper(r.Action.String())) + return strings.Join(parts, " "), nil +} + +// marshalLogLine encodes the LOG half of a logged rule: the same matches ending +// in a non-terminal LOG target carrying the optional prefix. +func (f *IPTables) marshalLogLine(r *Rule) (string, error) { + parts, err := f.marshalMatches(r) + if err != nil { + return "", err + } + parts = append(parts, "-j", "LOG") + if r.LogPrefix != "" { + quoted, err := f.quoteCommentToken(r.LogPrefix) + if err != nil { + return "", err + } + parts = append(parts, "--log-prefix", quoted) + } + return strings.Join(parts, " "), nil +} + +// marshalRuleLines returns the save-file lines representing r: a LOG line +// followed by the action line when r.Log is set (iptables cannot both log and +// take a terminal action in one rule), otherwise just the action line. +func (f *IPTables) marshalRuleLines(r *Rule) ([]string, error) { + action, err := f.MarshalRule(r) + if err != nil { + return nil, err + } + if !r.Log { + return []string{action}, nil + } + logLine, err := f.marshalLogLine(r) + if err != nil { + return nil, err + } + return []string{logLine, action}, nil +} + +// insertRuleGroups returns groups with each cell's line(s) spliced in before +// the first *filter group at reports true for, falling back to the table's +// COMMIT so a placement past a chain's end appends. A cell an equal logical +// rule already covers is left alone rather than duplicated (LOG+action lines +// scan as one group, so a logged rule compares as one unit), which also fills +// in a subset left by an earlier partial edit instead of re-adding whole. It +// is the shared body of AddRule and InsertRule; each passes its own placement +// predicate. +func (f *IPTables) insertRuleGroups(groups []iptGroup, cells []*Rule, at func(g iptGroup, cell *Rule) bool) ([]iptGroup, bool, error) { + // Validate and encode every cell's line(s) up front: a logged cell is a LOG + // line plus an action line, and a rejection or marshalling error must change + // nothing. The check runs per cell rather than on the caller's rule because + // editRuleFiles has already fanned the merged axes out — a TCPUDP rule is + // legitimate on the way in and only its concrete halves are expressible. + lines := make([][]string, len(cells)) + for i, cell := range cells { + if err := f.validateRule(cell); err != nil { + return nil, false, err + } + ls, err := f.marshalRuleLines(cell) + if err != nil { + return nil, false, err + } + lines[i] = ls + } + + // Note the cells the file already holds. With the groups in hand this is a + // pass over parsed slices, not a second read of the file. + placed := make([]bool, len(cells)) + for _, g := range groups { + if g.table != "filter" || g.rule == nil { + continue + } + for i, cell := range cells { + if !placed[i] && g.rule.EqualBase(cell, true) { + placed[i] = true + } + } + } + + // Splice each missing cell in ahead of the first group its predicate (or + // the filter COMMIT fallback) selects. + out := make([]iptGroup, 0, len(groups)+len(cells)) + changed := false + for _, g := range groups { + if g.table == "filter" { + for i, cell := range cells { + if !placed[i] && (at(g, cell) || g.commit) { + out = append(out, iptGroup{raw: lines[i]}) + placed[i] = true + changed = true + } + } + } + out = append(out, g) + } + + // A cell that found no placement point means the file carries no *filter + // table to hold it. + for i := range cells { + if !placed[i] { + return nil, false, fmt.Errorf("failed to write the new rule to the iptables-save file") + } + } + return out, changed, nil +} + +// AddRule adds a rule to the zone. A family-agnostic set-referencing rule is +// pinned to its set's family first, so its line lands only in the save file the +// single-family ipset can match. +func (f *IPTables) AddRule(ctx context.Context, zoneName string, r *Rule) error { + r, err := resolveSetRefRule(r, f.setRefFamily) + if err != nil { + return err + } + if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { + return err + } + // Insert before the first rule line of any chain — physically ahead of every + // rule line in the table, unmodeled lines included — or before the filter + // table's COMMIT when it holds none yet, so the rule lands at the top of its + // chain. + return f.editRuleFiles(r, func(groups []iptGroup, cells []*Rule) ([]iptGroup, bool, error) { + return f.insertRuleGroups(groups, cells, func(g iptGroup, _ *Rule) bool { + return g.chain != "" + }) + }) +} + +// iptChainForDirection returns the filter chain name (INPUT, OUTPUT or FORWARD) +// a rule of the given direction lives in. +func iptChainForDirection(d Direction) string { + switch d { + case DirOutput: + return "OUTPUT" + case DirForward: + return "FORWARD" + } + return "INPUT" +} + +// InsertRule inserts rule before the given 1-based position in the iptables save +// file. A non-positive position is treated as 1; a position larger than the +// current rule count appends the rule. A family-agnostic set-referencing rule is +// pinned to its set's family first, as in AddRule. +func (f *IPTables) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + r, err := resolveSetRefRule(r, f.setRefFamily) + if err != nil { + return err + } + if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { + return err + } + if position <= 0 { + position = 1 + } + // Match each cell's target chain by an exact chain-name compare, not a + // prefix: a foreign chain whose name merely starts with INPUT/OUTPUT (e.g. a + // firewalld "INPUT_direct" chain) must not be counted, or the 1-based + // position would diverge from the per-direction numbering GetRules reports. + // The scan numbered each group as GetRules does — a LOG+action pair is one + // logical rule holding both its lines, an unmodeled or orphan line is none — + // so the insert lands at the position GetRules reports and never splits a + // logged rule's two lines. + return f.editRuleFiles(r, func(groups []iptGroup, cells []*Rule) ([]iptGroup, bool, error) { + return f.insertRuleGroups(groups, cells, func(g iptGroup, cell *Rule) bool { + return g.chain == iptChainForDirection(cell.Direction) && g.number == position + }) + }) +} + +// moveCellGroups returns groups with the first group matching cell lifted and +// re-inserted at the 1-based position within its chain, reporting false when no +// group matches. The lifted group holds every line the rule occupies, so a +// logged rule's LOG and action lines move together and a standalone LOG line is +// never dragged along with an unrelated rule. +func (f *IPTables) moveCellGroups(groups []iptGroup, cell *Rule, position int) ([]iptGroup, bool, error) { + // Lift the first matching rule. + moved := -1 + for i, g := range groups { + if g.table == "filter" && g.rule != nil && g.rule.EqualBase(cell, true) { + moved = i + break + } + } + if moved < 0 { + return groups, false, nil + } + lifted := groups[moved] + // The three-index slice forces a copy rather than shifting groups in place. + rest := append(groups[:moved:moved], groups[moved+1:]...) + + // Re-insert at the requested 1-based position, renumbering the target chain + // over the post-removal groups: lifting the rule shifted every rule below it + // up by one, so the numbers the scan stored cannot be reused. Exact + // chain-name compare, as in InsertRule. A position past the chain's last + // rule falls through to the table's COMMIT, which appends after that last + // rule, so no clamp is needed (insertRuleGroups relies on the same fallback). + expectedChain := iptChainForDirection(cell.Direction) + out := make([]iptGroup, 0, len(rest)+1) + inserted := false + pos := 0 + for _, g := range rest { + if !inserted && g.table == "filter" { + if g.chain == expectedChain && g.rule != nil { + pos++ + if pos == position { + out = append(out, lifted) + inserted = true + } + } + if !inserted && g.commit { + out = append(out, lifted) + inserted = true + } + } + out = append(out, g) + } + + if !inserted { + return nil, false, fmt.Errorf("failed to move the rule in the iptables-save file") + } + return out, true, nil +} + +// MoveRule moves an existing rule to the given 1-based position within its chain. +func (f *IPTables) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { + return err + } + if position <= 0 { + position = 1 + } + // A merged rule moves cell by cell: each concrete half is lifted and + // re-inserted at the position within its own chain. A cell the file does not + // hold is skipped rather than failing the others. + return f.editRuleFiles(r, func(groups []iptGroup, cells []*Rule) ([]iptGroup, bool, error) { + changed := false + for _, cell := range cells { + out, moved, err := f.moveCellGroups(groups, cell, position) + if err != nil { + return nil, false, err + } + if moved { + groups = out + changed = true + } + } + return groups, changed, nil + }) +} + +// removeRuleGroups returns groups with every *filter group matching one of the +// rule's cells dropped, reporting whether any matched. The scan handed back +// whole logical rules, so a logged rule's LOG and action lines are dropped +// together while a standalone LOG line is left where it is, and the +// *nat/*mangle scoping that keeps a foreign nat rule from being removed as if +// it were a filter rule is the scanner's. Every group a cell matches goes in +// the one pass, so a chain holding the same rule twice comes back clean. +func (f *IPTables) removeRuleGroups(groups []iptGroup, cells []*Rule) ([]iptGroup, bool, error) { + out := make([]iptGroup, 0, len(groups)) + changed := false + for _, g := range groups { + matched := false + if g.table == "filter" && g.rule != nil { + for _, cell := range cells { + if g.rule.EqualBase(cell, true) { + matched = true + break + } + } + } + if matched { + changed = true + continue + } + out = append(out, g) + } + return out, changed, nil +} + +// RemoveRule removes a rule from the zone. A family-agnostic set-referencing +// rule is deliberately not pinned here: leaving it FamilyAny sweeps both family +// files, which also clears a stray wrong-family line and still works after the +// referenced set is gone. +func (f *IPTables) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + return f.editRuleFiles(r, f.removeRuleGroups) +} + +// fileFamily returns the IP family of one of this backend's save files. +func (f *IPTables) fileFamily(path string) Family { + if f.managesIPv6() && path == f.IP6Path { + return IPv6 + } + return IPv4 +} + +// natRulesInFile parses the nat-table rules from a save file. The scan drops the +// lines GetNATRules cannot report — a line the model cannot hold, and a +// container runtime's own translation such as Docker's per-published-port +// hairpin masquerade — so every nat group carrying a rule is one to return. +// GetNATRules assigns Number per family; the other callers use the result only +// for dedup and never read Number. +func (f *IPTables) natRulesInFile(path string) ([]*NATRule, error) { + var rules []*NATRule + err := f.scanSaveFile(path, f.fileFamily(path), func(g iptGroup) error { + if g.table == "nat" && g.nat != nil { + rules = append(rules, g.nat) + } + return nil + }) + if err != nil { + return nil, err + } + return rules, nil +} + +// GetNATRules returns the existing NAT rules from the zone. +func (f *IPTables) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { + // Each save-file line is its own NAT rule, pinned to the family of the file it + // lives in. Number each family's nat chains independently, as GetRules does for + // the filter chains, so a rule's Number matches the InsertNATRule/MoveNATRule + // position within the chain it actually lives in. + v4, err := f.natRulesInFile(f.IP4Path) + if err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err) + } + // An IPv4-only host has no v6 file to read. + var v6 []*NATRule + if f.managesIPv6() { + if v6, err = f.natRulesInFile(f.IP6Path); err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err) + } + } + numberNATByChain(v4) + numberNATByChain(v6) + rules = append(rules, v4...) + rules = append(rules, v6...) + return rules, nil +} + +// natChain returns the nat-table chain a NAT rule belongs in. +func (f *IPTables) natChain(r *NATRule) string { + if r.Kind.isSource() { + return "POSTROUTING" + } + return "PREROUTING" +} + +// natTarget renders an iptables NAT translation target "addr" or "addr:port", +// bracketing an IPv6 address when a port is present. +func natTarget(fam Family, addr string, port uint16) string { + if port == 0 { + return addr + } + if fam == IPv6 || familyOfAddr(addr) == IPv6 { + return fmt.Sprintf("[%s]:%d", addr, port) + } + return fmt.Sprintf("%s:%d", addr, port) +} + +// MarshalNATRule encodes a NAT rule as an iptables-save rulespec for the nat +// table (e.g. `-A PREROUTING -p tcp --dport 80 -j DNAT --to-destination ...`). +// It is a pure encoder: callers run NATRule.validate first. iptables' nat targets +// cover every modeled shape, so it takes no rejections of its own. +func (f *IPTables) MarshalNATRule(r *NATRule) (string, error) { + fam := r.impliedFamily() + parts := []string{"-A", f.natChain(r)} + + if r.Source != "" { + parts = append(parts, f.addrArgs(r.Source, "src")...) + } + if r.Destination != "" { + parts = append(parts, f.addrArgs(r.Destination, "dst")...) + } + + // Interface, bound to the translation direction. + if r.Interface != "" { + if r.Kind.isSource() { + parts = append(parts, "-o", r.Interface) + } else { + parts = append(parts, "-i", r.Interface) + } + } + + if r.Proto != ProtocolAny { + parts = append(parts, "-p", r.Proto.String()) + } + + specs := r.PortSpecs() + if len(specs) == 1 && specs[0].Start == specs[0].End { + parts = append(parts, "-m", r.Proto.String(), "--dport", strconv.FormatUint(uint64(specs[0].Start), 10)) + } else if len(specs) > 0 { + parts = append(parts, "-m", "multiport", "--dports", iptMultiportValue(specs)) + } + + if f.rulePrefix != "" { + quoted, err := f.quoteCommentToken(f.rulePrefix) + if err != nil { + return "", err + } + parts = append(parts, "-m", "comment", "--comment", quoted) + } + + switch r.Kind { + case DNAT: + parts = append(parts, "-j", "DNAT", "--to-destination", natTarget(fam, r.ToAddress, r.ToPort)) + case Redirect: + parts = append(parts, "-j", "REDIRECT", "--to-ports", strconv.FormatUint(uint64(r.ToPort), 10)) + case SNAT: + parts = append(parts, "-j", "SNAT", "--to-source", natTarget(fam, r.ToAddress, r.ToPort)) + case Masquerade: + parts = append(parts, "-j", "MASQUERADE") + } + + return strings.Join(parts, " "), nil +} + +// editNATFile inserts or removes a NAT rule line within a save file's nat table +// in a single streamed pass, creating the table section when adding to a file +// that lacks one. An add is a no-op when the table already holds an equivalent +// rule; a removal drops every matching line, not just the first, so a chain +// holding duplicate equivalent lines comes clean in one call — mirroring +// removeRuleGroups. A container runtime's translation is invisible to the +// scan, so it is never a removal target, as on the filter side. +func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) error { + fd, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = fd.Close() }() + + af, err := newAtomicFile(path, 0644) + if err != nil { + return err + } + defer af.Abort() + + changed, duplicate, sawNAT := false, false, false + err = f.scanSaveGroups(fd, f.fileFamily(path), func(g iptGroup) error { + if g.table == "nat" { + if g.commit { + sawNAT = true + } + switch { + case !add: + if g.nat != nil && g.nat.EqualBase(r) { + changed = true + return nil + } + case g.nat != nil && g.nat.EqualBase(r): + duplicate = true + case g.commit && !changed && !duplicate: + // Insert before the table's COMMIT. + _, _ = fmt.Fprintln(af, line) + changed = true + } + } + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + return nil + }) + // A read error means the staged file is truncated; discard it. + if err != nil { + return err + } + + if add { + if duplicate { + return nil + } + // The file carries no usable nat table; append a fresh one holding the rule. + if !sawNAT { + for _, l := range defaultNATSection[:len(defaultNATSection)-1] { + _, _ = fmt.Fprintln(af, l) + } + _, _ = fmt.Fprintln(af, line) + _, _ = fmt.Fprintln(af, "COMMIT") + changed = true + } + } + if !changed { + return nil + } + return af.Commit() +} + +// natPaths returns the save files a NAT rule applies to, per its family. +func (f *IPTables) natPaths(r *NATRule) []string { + // As in editRuleFiles, an IPv4-only host contributes no v6 path. + fam := r.impliedFamily() + var paths []string + if fam == IPv4 || fam == FamilyAny { + paths = append(paths, f.IP4Path) + } + if (fam == IPv6 || fam == FamilyAny) && f.managesIPv6() { + paths = append(paths, f.IP6Path) + } + return paths +} + +// defaultNATSection is the nat table scaffold written when a save file has none. +var defaultNATSection = []string{ + "*nat", + ":PREROUTING ACCEPT [0:0]", + ":INPUT ACCEPT [0:0]", + ":OUTPUT ACCEPT [0:0]", + ":POSTROUTING ACCEPT [0:0]", + "COMMIT", +} + +// AddNATRule adds a NAT rule to the zone. A family-agnostic set-referencing +// rule is pinned to its set's family first, as with AddRule. +func (f *IPTables) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := r.validate(); err != nil { + return err + } + r, err := resolveSetRefNAT(r, f.setRefFamily) + if err != nil { + return err + } + if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { + return err + } + line, err := f.MarshalNATRule(r) + if err != nil { + return err + } + for _, path := range f.natPaths(r) { + if err := f.editNATFile(path, r, line, true); err != nil { + return err + } + } + return nil +} + +// insertNATFile inserts a NAT rule line at the given 1-based position within its +// nat chain, creating the table section when the file lacks one. Position counts +// only lines in the rule's own chain (PREROUTING or POSTROUTING); a non-positive +// position is treated as 1 and a position past the chain's end appends after the +// chain's last rule. +func (f *IPTables) insertNATFile(path string, r *NATRule, line string, position int) error { + if position <= 0 { + position = 1 + } + // Exact chain-name compare so a foreign chain whose name starts with the + // target chain (e.g. "PREROUTING_direct") is not counted (see + // InsertRule). The scan numbers each group as GetNATRules does, so + // a line the model cannot hold takes no position with it. + chainName := f.natChain(r) + + fd, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = fd.Close() }() + + af, err := newAtomicFile(path, 0644) + if err != nil { + return err + } + defer af.Abort() + + written, duplicate, sawNAT, seenChain := false, false, false, false + writeRule := func() { + _, _ = fmt.Fprintln(af, line) + written = true + } + err = f.scanSaveGroups(fd, f.fileFamily(path), func(g iptGroup) error { + if g.table == "nat" { + if g.commit { + sawNAT = true + } + if g.nat != nil && g.nat.EqualBase(r) { + duplicate = true + } + if !written && !duplicate { + switch { + case g.chain == chainName && g.number == position: + writeRule() + case seenChain && g.chain != "" && g.chain != chainName: + // The position ran past the chain's last rule: append directly + // after it rather than at the table's COMMIT, so the chain's lines + // stay together instead of landing behind another chain's. + writeRule() + case g.commit: + // A chain with no rules of its own still lands inside the table. + writeRule() + } + } + if g.chain == chainName { + seenChain = true + } + } + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + return nil + }) + // A read error means the staged file is truncated; discard it. + if err != nil { + return err + } + // Skip when an equivalent rule already exists. + if duplicate { + return nil + } + // The file carries no usable nat table; append a fresh one holding the rule. + // A table header with no COMMIT under it still lets the position and + // chain-run branches above place the rule, so append only when nothing was + // written — otherwise the rule would land twice. + if !sawNAT && !written { + for _, l := range defaultNATSection[:len(defaultNATSection)-1] { + _, _ = fmt.Fprintln(af, l) + } + _, _ = fmt.Fprintln(af, line) + _, _ = fmt.Fprintln(af, "COMMIT") + return af.Commit() + } + if !written { + return nil + } + return af.Commit() +} + +// InsertNATRule inserts a NAT rule at the given 1-based position within its nat +// chain. A non-positive position is treated as 1; a position larger than the +// chain's current rule count appends the rule. +func (f *IPTables) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + if err := r.validate(); err != nil { + return err + } + if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { + return err + } + line, err := f.MarshalNATRule(r) + if err != nil { + return err + } + for _, path := range f.natPaths(r) { + if err := f.insertNATFile(path, r, line, position); err != nil { + return err + } + } + return nil +} + +// moveNATFile relocates the first NAT rule equal to r within its chain to the +// given 1-based position, mirroring moveCellGroups for the nat table. The +// matched line is lifted and re-placed verbatim so its comment prefix and +// formatting survive; only its position in the chain changes. A non-positive +// position is treated as 1; a position past the chain's end appends. It is a +// no-op (nil) when the rule is not present or the file has no nat table. +func (f *IPTables) moveNATFile(path string, r *NATRule, position int) error { + if position <= 0 { + position = 1 + } + // Exact chain-name compare so a foreign chain whose name starts with the + // target chain is not counted. + chainName := f.natChain(r) + + // As on the filter side, a move is the one nat edit that cannot stream: the + // rule's new slot may sit ahead of the one it was lifted from. Holding the + // scanned groups keeps the file read and parsed exactly once. + var groups []iptGroup + if err := f.scanSaveFile(path, f.fileFamily(path), func(g iptGroup) error { + groups = append(groups, g) + return nil + }); err != nil { + return err + } + + // Lift the first matching rule verbatim, so its comment prefix and formatting + // survive; only its position in the chain changes. + moved := -1 + for i, g := range groups { + if g.table == "nat" && g.nat != nil && g.nat.EqualBase(r) { + moved = i + break + } + } + if moved < 0 { + return nil + } + lifted := groups[moved] + // The three-index slice forces a copy rather than shifting groups in place. + groups = append(groups[:moved:moved], groups[moved+1:]...) + + af, err := newAtomicFile(path, 0644) + if err != nil { + return err + } + defer af.Abort() + + // Re-insert at the requested position within the rule's chain, renumbering + // over the post-removal groups. A position past the chain's last rule appends + // after it, and a chain with no surviving rules falls back to the table's + // COMMIT, mirroring insertNATFile. + written, seenChain := false, false + pos := 0 + writeLifted := func() { + for _, l := range lifted.raw { + _, _ = fmt.Fprintln(af, l) + } + written = true + } + for _, g := range groups { + if g.table == "nat" && !written { + if g.chain == chainName && g.nat != nil { + pos++ + } + switch { + case g.chain == chainName && g.nat != nil && pos == position: + writeLifted() + case seenChain && g.chain != "" && g.chain != chainName: + writeLifted() + case g.commit: + writeLifted() + } + if g.chain == chainName { + seenChain = true + } + } + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + } + if !written { + return nil + } + return af.Commit() +} + +// MoveNATRule moves an existing NAT rule to the given 1-based position within +// its nat chain. A non-positive position is treated as 1; a position larger than +// the chain's current rule count moves the rule to the end. +func (f *IPTables) MoveNATRule(ctx context.Context, zoneName string, r *NATRule, position int) error { + if err := f.checkFamilyManaged(r.impliedFamily()); err != nil { + return err + } + for _, path := range f.natPaths(r) { + if err := f.moveNATFile(path, r, position); err != nil { + return err + } + } + return nil +} + +// RemoveNATRule removes a NAT rule from the zone. +func (f *IPTables) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + for _, path := range f.natPaths(r) { + if err := f.editNATFile(path, r, "", false); err != nil { + return err + } + } + return nil +} + +// parsePolicyLine decodes a `:CHAIN POLICY [counters]` chain declaration. +func (f *IPTables) parsePolicyLine(line string) (chain string, action Action, ok bool) { + t := strings.TrimSpace(line) + if !strings.HasPrefix(t, ":") { + return "", 0, false + } + fields := strings.Fields(t) + if len(fields) < 2 { + return "", 0, false + } + switch fields[1] { + case "ACCEPT": + action = Accept + case "DROP": + action = Drop + default: + return "", 0, false + } + return strings.TrimPrefix(fields[0], ":"), action, true +} + +// policyFromFile reads the INPUT/OUTPUT/FORWARD chain policies from an +// iptables-save file. A direction whose chain line is absent is reported as +// ActionInvalid. +func (f *IPTables) policyFromFile(path string) (*DefaultPolicy, error) { + p := &DefaultPolicy{} + // Only the *filter table carries the input/output/forward policy. The other + // tables (*nat, *mangle, *raw, ...) declare their own :INPUT/:OUTPUT built-in + // chains — *nat's is always ACCEPT (iptables rejects any other policy there), + // while *mangle/*raw can carry any policy but are not filtering tables — and + // iptables-save emits them after *filter, so scanning table-agnostically would + // let one of those chains shadow a hardened filter policy (e.g. report + // Input=Accept when filter INPUT is DROP). The scan supplies the table scope. + err := f.scanSaveFile(path, FamilyAny, func(g iptGroup) error { + if g.table != "filter" { + return nil + } + chain, action, ok := f.parsePolicyLine(g.raw[0]) + if !ok { + return nil + } + switch chain { + case "INPUT": + p.Input = action + case "OUTPUT": + p.Output = action + case "FORWARD": + p.Forward = action + } + return nil + }) + if err != nil { + return nil, err + } + return p, nil +} + +// GetDefaultPolicy returns the default action applied to packets that match no rule. +func (f *IPTables) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + v4, err := f.policyFromFile(f.IP4Path) + if err != nil { + return nil, err + } + // An IPv4-only host has only the one policy to report, so there is nothing to + // reconcile against. + if !f.managesIPv6() { + return v4, nil + } + v6, err := f.policyFromFile(f.IP6Path) + if err != nil { + return nil, err + } + // SetDefaultPolicy writes both families identically, so on a host this library + // manages they always agree. A divergence means the IPv4 and IPv6 chain + // policies were set out of band and there is no single policy to report. + if *v4 != *v6 { + return nil, fmt.Errorf("iptables default policy differs between IPv4 (%+v) and IPv6 (%+v)", *v4, *v6) + } + return v4, nil +} + +// savePaths returns the save files this host manages, IPv4 first. It is the +// whole-file counterpart to editRuleFiles' per-rule family fan-out, for the +// operations that touch every managed file regardless of any one rule's family. +func (f *IPTables) savePaths() []string { + if !f.managesIPv6() { + return []string{f.IP4Path} + } + return []string{f.IP4Path, f.IP6Path} +} + +// setPolicyFile rewrites the chain declaration lines in an iptables-save +// file for the directions named in policy, preserving the counter slots. +func (f *IPTables) setPolicyFile(path string, policy *DefaultPolicy) error { + fd, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = fd.Close() }() + + af, err := newAtomicFile(path, 0644) + if err != nil { + return err + } + defer af.Abort() + + // Only rewrite policy lines inside the *filter table; the other tables + // (nat/mangle/raw/...) declare their own built-in chains — nat's must stay + // ACCEPT (iptables rejects any other policy there), and mangle/raw are not + // filtering tables regardless — so leave them all untouched. + err = f.scanSaveGroups(fd, FamilyAny, func(g iptGroup) error { + raw := g.raw[0] + if g.table == "filter" { + if chain, _, ok := f.parsePolicyLine(raw); ok { + var action Action + switch chain { + case "INPUT": + action = policy.Input + case "OUTPUT": + action = policy.Output + case "FORWARD": + action = policy.Forward + } + // A direction the caller left unset keeps the file's own policy. + if action != ActionInvalid { + fields := strings.Fields(raw) + counters := "[0:0]" + if len(fields) >= 3 { + counters = fields[2] + } + raw = fmt.Sprintf("%s %s %s", fields[0], strings.ToUpper(action.String()), counters) + } + } + } + _, _ = fmt.Fprintln(af, raw) + for _, l := range g.raw[1:] { + _, _ = fmt.Fprintln(af, l) + } + return nil + }) + if err != nil { + return err + } + return af.Commit() +} + +// SetDefaultPolicy sets the default action for the directions named in policy. +func (f *IPTables) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + if policy == nil { + return fmt.Errorf("policy cannot be nil") + } + for _, action := range []Action{policy.Input, policy.Output, policy.Forward} { + if action == Reject { + return fmt.Errorf("iptables chain policy may only be accept or drop") + } + } + for _, path := range f.savePaths() { + if err := f.setPolicyFile(path, policy); err != nil { + return err + } + } + return nil +} + +// --- address sets (ipset) --------------------------------------------------- +// +// Address sets follow the same staged model as this backend's rules: a mutation +// edits the ipset staging file and nothing reaches the kernel until Reload, +// which loads the file before restarting the rules services so a `-m set` rule +// resolves. Reading and writing the file rather than the live kernel is what +// keeps a set another tool created to be temporary — a fail2ban ban, a hand-made +// test set — out of the boot configuration, and it lets a rule removal and the +// removal of the set it referenced land in one Reload instead of failing on +// "in use by a kernel component". +// +// A host with no persistence mechanism (IPSetPath empty) has no staging file to +// edit, so sets there are created live immediately and warned about; see +// setPersistedStaging. +// +// Everything that touches the kernel goes through the netlink primitives in +// ipset_linux.go. The one exception is applyStagedSets, which shells out to +// `ipset restore`: that tool owns the save format, parses the entries and picks +// each type's revision, and reproducing all three over netlink to load a file we +// wrote in its own format would be work for its own sake. + +// ipsetParseType reads the family and type out of an ipset `create` line's +// trailing options. +func (f *IPTables) ipsetParseType(fields []string) (Family, SetType) { + family := IPv4 + t := SetHashIP + for i := 2; i < len(fields); i++ { + switch fields[i] { + case "hash:net": + t = SetHashNet + case "hash:ip": + t = SetHashIP + case "family": + if i+1 < len(fields) && fields[i+1] == "inet6" { + family = IPv6 + } + } + } + return family, t +} + +// ipsetSaveScanner decodes ipset save-format lines into address sets in +// create-line order. The staging file is streamed through it straight off disk, +// so a large blocklist is never held in memory twice — once as raw lines and +// again as parsed entries. +// +// The decode is single-pass: `ipset save` emits each set's create line ahead of +// its members, and `ipset restore` rejects any other order, so an add line whose +// set has not been seen names no set this file declares and is dropped. +type ipsetSaveScanner struct { + sets map[string]*AddressSet + order []string +} + +// line folds one ipset save-format line into the sets decoded so far. +func (s *ipsetSaveScanner) line(f *IPTables, line string) { + fields := strings.Fields(line) + if len(fields) < 3 { + return + } + switch fields[0] { + case "create": + if s.sets == nil { + s.sets = map[string]*AddressSet{} + } + if _, dup := s.sets[fields[1]]; dup { + return + } + family, t := f.ipsetParseType(fields) + s.sets[fields[1]] = &AddressSet{Name: fields[1], Family: family, Type: t} + s.order = append(s.order, fields[1]) + case "add": + // An add line may carry entry options (`timeout 600`, `comment "x"`); + // the entry itself is still the third field. + if set, ok := s.sets[fields[1]]; ok { + set.Entries = append(set.Entries, fields[2]) + } + } +} + +// result returns the decoded sets in create-line order. +func (s *ipsetSaveScanner) result() []*AddressSet { + out := make([]*AddressSet, 0, len(s.order)) + for _, n := range s.order { + out = append(out, s.sets[n]) + } + return out +} + +// scanIPSetSave streams an ipset save-format file off disk into address sets. A +// file that does not exist decodes as no sets: nothing has been staged yet. +func (f *IPTables) scanIPSetSave(path string) ([]*AddressSet, error) { + fd, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = fd.Close() }() + var s ipsetSaveScanner + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + s.line(f, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return s.result(), nil +} + +// stagedSets reads the sets staged in the layout's ipset file, the authority for +// what this backend manages. Callers that can act on a damaged file take the +// error; setRefFamily and other best-effort readers use persistedIPSetSets. +func (f *IPTables) stagedSets() ([]*AddressSet, error) { + if f.IPSetPath == "" { + return nil, nil + } + return f.scanIPSetSave(f.IPSetPath) +} + +// persistedIPSetSets reads the staged sets for family resolution. An +// unconfigured layout or an unreadable file means no sets — resolution falls +// through to the live kernel rather than failing the write. +func (f *IPTables) persistedIPSetSets() []*AddressSet { + sets, err := f.stagedSets() + if err != nil { + return nil + } + return sets +} + +// setRefFamily resolves the single family of the ipset(s) a rule references +// through the shared ipset resolver: the live kernel first, then the layout's +// ipset persistence file for a set declared only in boot config. A set neither +// knows is an error: writing the rule anyway would put a line no restore can +// load into a save file. A caller that knows better sets the rule's Family, +// which bypasses resolution entirely. +func (f *IPTables) setRefFamily(source, destination string) (Family, error) { + return ipsetRefFamily(source, destination, func() ([]*AddressSet, error) { + return f.persistedIPSetSets(), nil + }) +} + +// setPersistedStaging reports whether this host has an ipset staging file to manage +// sets through. When it does not, the address-set methods fall back to acting on +// the live kernel directly. +func (f *IPTables) setPersistedStaging() bool { + return f.IPSetPath != "" +} + +// GetAddressSets returns the address sets managed by this backend: the staged +// sets, or the live kernel's on a host with no staging file. +func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + if f.setPersistedStaging() { + return f.stagedSets() + } + return ipsetLiveSets() +} + +// GetAddressSet returns a single address set by name, or an error if it does not exist. +func (f *IPTables) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + sets, err := f.GetAddressSets(ctx) + if err != nil { + return nil, err + } + for _, s := range sets { + if s.Name == name { + return s, nil + } + } + return nil, fmt.Errorf("address set %q not found", name) +} + +// ipsetTypeSpec renders the ipset type keyword and family option for a set. +func (f *IPTables) ipsetTypeSpec(family Family, t SetType) string { + spec := t.String() + fam := "inet" + if family == IPv6 { + fam = "inet6" + } + return spec + " family " + fam +} + +// marshalIPSetSave renders sets in ipset save format, the format the staging +// file keeps so the distro's own restore unit and `ipset save` tooling read it +// unchanged. +func (f *IPTables) marshalIPSetSave(sets []*AddressSet) []byte { + var b strings.Builder + for _, s := range sets { + b.WriteString("create " + s.Name + " " + f.ipsetTypeSpec(s.Family, s.Type) + "\n") + for _, e := range s.Entries { + b.WriteString("add " + s.Name + " " + e + "\n") + } + } + return []byte(b.String()) +} + +// writeStagedSets replaces the staging file with sets and makes sure the restore +// unit is enabled, so a reboot loads them before the rules service loads the +// rules that reference them. +func (f *IPTables) writeStagedSets(ctx context.Context, sets []*AddressSet) error { + if f.IPSetService != "" { + if err := enableService(ctx, f.IPSetService); err != nil { + return err + } + } + return writeConfigFile(f.IPSetPath, f.marshalIPSetSave(sets), 0600) +} + +// setFamily resolves a set's stored family: a set is family-typed, and +// FamilyAny is recorded as IPv4 so the staged file always names a concrete one. +func (f *IPTables) setFamily(set *AddressSet) Family { + if set.Family == FamilyAny { + return IPv4 + } + return set.Family +} + +// editStagedSets reads the staged sets, hands them to edit, and writes the +// result back when edit reports a change. +func (f *IPTables) editStagedSets(ctx context.Context, edit func(sets []*AddressSet) ([]*AddressSet, bool, error)) error { + sets, err := f.stagedSets() + if err != nil { + return err + } + out, changed, err := edit(sets) + if err != nil { + return err + } + if !changed { + return nil + } + return f.writeStagedSets(ctx, out) +} + +// dropPendingRemoval cancels a staged removal for name, for a set added back +// before the Reload that would have destroyed it. +func (f *IPTables) dropPendingRemoval(name string) { + for i, n := range f.pendingSetRemovals { + if n == name { + f.pendingSetRemovals = append(f.pendingSetRemovals[:i], f.pendingSetRemovals[i+1:]...) + return + } + } +} + +// liveAddAddressSet creates a set directly in the kernel, the fallback on a host +// with no staging file to write. +func (f *IPTables) liveAddAddressSet(ctx context.Context, set *AddressSet) error { + if err := ipsetCreate(set.Name, f.setFamily(set), set.Type); err != nil { + return err + } + for _, entry := range set.Entries { + if err := ipsetAddEntry(set.Name, entry); err != nil { + return err + } + } + return nil +} + +// AddAddressSet stages an address set; it is created in the kernel by Reload. +// Adding a set that already exists by name merges its entries, matching what a +// re-create with -exist does live; re-declaring one with a different family or +// type is a conflict and is reported rather than silently rewriting the set. +func (f *IPTables) AddAddressSet(ctx context.Context, set *AddressSet) error { + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + if !f.setPersistedStaging() { + log.Printf("firewall: address sets are live-only; no ipset persistence mechanism found, they will not survive a reboot") + return f.liveAddAddressSet(ctx, set) + } + err := f.editStagedSets(ctx, func(sets []*AddressSet) ([]*AddressSet, bool, error) { + family := f.setFamily(set) + for _, s := range sets { + if s.Name != set.Name { + continue + } + if s.Family != family || s.Type != set.Type { + return nil, false, fmt.Errorf("address set %q is already staged as %s %s, not %s %s", + set.Name, s.Family, s.Type, family, set.Type) + } + // Merge the requested entries into the existing set. + changed := false + for _, e := range set.Entries { + if !slices.Contains(s.Entries, e) { + s.Entries = append(s.Entries, e) + changed = true + } + } + return sets, changed, nil + } + add := &AddressSet{Name: set.Name, Family: family, Type: set.Type} + add.Entries = append(add.Entries, set.Entries...) + return append(sets, add), true, nil + }) + if err != nil { + return err + } + f.dropPendingRemoval(set.Name) + return nil +} + +// RemoveAddressSet removes an address set by name. The set is dropped from the +// staging file now and destroyed in the kernel by Reload, once the rules that +// referenced it are gone. Removing a set that is not staged is a no-op. +func (f *IPTables) RemoveAddressSet(ctx context.Context, name string) error { + if !f.setPersistedStaging() { + return f.liveRemoveAddressSet(ctx, name) + } + removed := false + err := f.editStagedSets(ctx, func(sets []*AddressSet) ([]*AddressSet, bool, error) { + out := make([]*AddressSet, 0, len(sets)) + for _, s := range sets { + if s.Name == name { + removed = true + continue + } + out = append(out, s) + } + return out, removed, nil + }) + if err != nil { + return err + } + // Queue the kernel-side destroy for Reload. A set that was never staged has + // nothing to destroy, so an already-gone set stays a clean no-op. + if removed && !slices.Contains(f.pendingSetRemovals, name) { + f.pendingSetRemovals = append(f.pendingSetRemovals, name) + } + return nil +} + +// liveRemoveAddressSet destroys a set directly in the kernel, the fallback on a +// host with no staging file, and the path Reload takes for a staged removal. +func (f *IPTables) liveRemoveAddressSet(ctx context.Context, name string) error { + // Empty the set before destroying it. A missing set is a no-op in both steps; + // any other failure — notably the kernel refusing to destroy a set a loaded + // rule still matches on — is real and must be surfaced rather than reported as + // success while the set remains. + if err := ipsetFlush(name); err != nil { + return err + } + return ipsetDestroy(name) +} + +// AddAddressSetEntry adds an entry to the named set. +func (f *IPTables) AddAddressSetEntry(ctx context.Context, name, entry string) error { + if !f.setPersistedStaging() { + return ipsetAddEntry(name, entry) + } + return f.editStagedSets(ctx, func(sets []*AddressSet) ([]*AddressSet, bool, error) { + for _, s := range sets { + if s.Name != name { + continue + } + if slices.Contains(s.Entries, entry) { + return sets, false, nil + } + s.Entries = append(s.Entries, entry) + return sets, true, nil + } + // Mirrors what `ipset add` reports against a set the kernel does not hold. + return nil, false, fmt.Errorf("address set %q does not exist", name) + }) +} + +// RemoveAddressSetEntry removes an entry from the named set. A missing entry, or +// a missing set, is a no-op. +func (f *IPTables) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + if !f.setPersistedStaging() { + return ipsetDelEntry(name, entry) + } + return f.editStagedSets(ctx, func(sets []*AddressSet) ([]*AddressSet, bool, error) { + for _, s := range sets { + if s.Name != name { + continue + } + if i := slices.Index(s.Entries, entry); i >= 0 { + s.Entries = append(s.Entries[:i], s.Entries[i+1:]...) + return sets, true, nil + } + } + return sets, false, nil + }) +} + +// applyStagedSets loads the staging file into the kernel. Each declared set is +// created if missing, then flushed and refilled so it ends up matching the file +// exactly; a set the file does not declare is left alone, since the kernel also +// holds sets other tools own and reconcile (kube-proxy, Calico, fail2ban). +func (f *IPTables) applyStagedSets(ctx context.Context) error { + if !f.setPersistedStaging() { + return nil + } + sets, err := f.stagedSets() + if err != nil { + return err + } + if len(sets) == 0 { + return nil + } + var script strings.Builder + for _, s := range sets { + script.WriteString("create " + s.Name + " " + f.ipsetTypeSpec(s.Family, s.Type) + " -exist\n") + script.WriteString("flush " + s.Name + "\n") + for _, e := range s.Entries { + script.WriteString("add " + s.Name + " " + e + "\n") + } + } + _, err = runCommandStdin(ctx, script.String(), "ipset", "restore") + return err +} + +// applyPendingSetRemovals destroys the sets removed from the staging file this +// session. It runs after the rules services restart, so the rules that +// referenced them are already gone and the destroy is not refused as in-use. A +// set that will not destroy stays queued for the next Reload. +func (f *IPTables) applyPendingSetRemovals(ctx context.Context) error { + if len(f.pendingSetRemovals) == 0 { + return nil + } + var failed []string + var firstErr error + for _, name := range f.pendingSetRemovals { + if err := f.liveRemoveAddressSet(ctx, name); err != nil { + failed = append(failed, name) + if firstErr == nil { + firstErr = err + } + } + } + f.pendingSetRemovals = failed + return firstErr +} + +// Backup captures the current filter and NAT rules managed by this backend. +func (f *IPTables) Backup(ctx context.Context, zoneName string) (*Backup, error) { + rules, err := f.GetRules(ctx, zoneName) + if err != nil { + return nil, err + } + natRules, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return nil, err + } + // Backup captures the INPUT/OUTPUT/FORWARD filter rules, the nat rules, the + // filter chain default policies and the managed ipsets; Restore replaces exactly + // those on replay, leaving user-defined chains and other tables (which Backup + // does not capture) intact. + backup := &Backup{Rules: rules, NATRules: natRules} + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil +} + +// modeledFilterChain reports whether a *filter chain name is one the library +// models as a Rule direction (INPUT, OUTPUT or FORWARD). The file-rewrite paths +// use it to tell a managed rule from a rule in a chain the library does not model +// (a user-defined chain), which must be preserved verbatim. +func (f *IPTables) modeledFilterChain(ch string) bool { + switch ch { + case "INPUT", "OUTPUT", "FORWARD": + return true + } + return false +} + +// rewriteFilterRules atomically rewrites path so that the *filter table's rule +// (-A) lines are exactly ruleLines, leaving the chain-policy lines, any *nat +// table and all other content untouched. A file with no *filter table gains one. +// +// A modeled-chain (INPUT/OUTPUT/FORWARD) line is dropped only when the scan +// resolved it to a rule, because only then can the desired set reproduce it. +// Every line the scan leaves unresolved is kept verbatim, on the same principle +// the library already applies to a user-defined chain — a rule it does not model +// must not be deleted just because it is invisible. Three kinds qualify: +// - A line the rule parser rejects outright — a foreign rule using a match this +// library does not model (e.g. -m recent, -m owner, --tcp-flags). +// - A line a container runtime owns, which GetRules never reports and whose +// deletion would sever live container networking. +// - A standalone LOG rule — a non-terminal `-j LOG` line with no action partner +// immediately after it. GetRules coalesces a LOG line with its following +// action line into one logged rule and drops an unpaired one, so it too is +// unmodeled. A LOG line that DID pair is part of its group's resolved rule +// and is dropped with it. +func (f *IPTables) rewriteFilterRules(path string, ruleLines []string) error { + fd, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = fd.Close() }() + + af, err := newAtomicFile(path, 0644) + if err != nil { + return err + } + defer af.Abort() + + inserted := false + err = f.scanSaveGroups(fd, FamilyAny, func(g iptGroup) error { + if g.table == "filter" { + switch { + case g.commit: + if !inserted { + for _, l := range ruleLines { + _, _ = fmt.Fprintln(af, l) + } + inserted = true + } + // The library models the INPUT, OUTPUT and FORWARD chains, so the + // desired set can only ever contain those. Drop an existing modeled rule + // (counter-annotated or not) — the desired set replaces it — but keep a + // rule in any other chain verbatim, since parseFilterFile never captures + // those chains and dropping them would silently delete rules the library + // does not manage. + case g.chain != "" && f.modeledFilterChain(g.chain) && g.rule != nil: + return nil + } + } + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + return nil + }) + if err != nil { + return err + } + if !inserted { + for _, l := range []string{"*filter", ":INPUT ACCEPT [0:0]", ":OUTPUT ACCEPT [0:0]", ":FORWARD ACCEPT [0:0]"} { + _, _ = fmt.Fprintln(af, l) + } + for _, l := range ruleLines { + _, _ = fmt.Fprintln(af, l) + } + _, _ = fmt.Fprintln(af, "COMMIT") + } + return af.Commit() +} + +// managedNATChain reports whether a nat-table chain is one this backend reads +// and writes (PREROUTING/POSTROUTING). rewriteNATRules replaces the rules in these +// chains and preserves every other nat chain verbatim — including OUTPUT, whose +// locally-generated DNAT the NATRule model cannot represent distinctly (see +// UnmarshalNATRule), so it is left untouched rather than relocated to PREROUTING. +func (f *IPTables) managedNATChain(chain string) bool { + switch chain { + case "PREROUTING", "POSTROUTING": + return true + } + return false +} + +// rewriteNATRules atomically rewrites path so that the *nat table's rule lines in +// the managed chains are exactly natLines, leaving the chain-policy lines, any +// user-defined nat chain, unmodeled managed-chain lines, the *filter table and +// all other content untouched. A file with no *nat table gains one. It is the +// nat counterpart of rewriteFilterRules, and preserves an unresolved +// managed-chain line on the same grounds: an unsupported jump (-j DOCKER, -j +// RETURN), an unmodeled or negated match, a source-port match, or a container +// runtime's own translation is invisible to GetNATRules, so it never appears in +// the desired set and dropping it would sever — for example — Docker's port +// publishing on a Backup/Restore round trip. +func (f *IPTables) rewriteNATRules(path string, natLines []string) error { + fd, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = fd.Close() }() + + af, err := newAtomicFile(path, 0644) + if err != nil { + return err + } + defer af.Abort() + + inserted := false + err = f.scanSaveGroups(fd, FamilyAny, func(g iptGroup) error { + if g.table == "nat" { + switch { + case g.commit: + if !inserted { + for _, l := range natLines { + _, _ = fmt.Fprintln(af, l) + } + inserted = true + } + case g.chain != "" && f.managedNATChain(g.chain) && g.nat != nil: + return nil + } + } + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + return nil + }) + if err != nil { + return err + } + if !inserted { + for _, l := range defaultNATSection[:len(defaultNATSection)-1] { + _, _ = fmt.Fprintln(af, l) + } + for _, l := range natLines { + _, _ = fmt.Fprintln(af, l) + } + _, _ = fmt.Fprintln(af, "COMMIT") + } + return af.Commit() +} + +// Restore replaces the managed INPUT/OUTPUT/FORWARD filter rules and the nat rules +// with the contents of a Backup, splicing them into each family's existing save +// file, and re-asserts the captured filter chain policies and ipsets. User-defined +// chains and the *mangle/*raw tables — none of which Backup captures — are left +// untouched. +func (f *IPTables) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + + // Stage the ipsets first so a set-referencing rule (@set) resolves against a + // declared set while the save files below are rewritten, and so the caller's + // Reload loads the sets ahead of the rules. AddAddressSet merges into an + // already-staged set, making a restore over existing state idempotent. + if err := restoreBackupSets(ctx, f, backup, false); err != nil { + return err + } + + // Group rules by family. + groupRules := func() map[Family][]*Rule { + m := map[Family][]*Rule{} + for _, r := range backup.Rules { + fam := r.impliedFamily() + if fam == FamilyAny { + m[IPv4] = append(m[IPv4], r) + m[IPv6] = append(m[IPv6], r) + } else { + m[fam] = append(m[fam], r) + } + } + return m + } + + groupNAT := func() map[Family][]*NATRule { + m := map[Family][]*NATRule{} + for _, r := range backup.NATRules { + fam := r.impliedFamily() + if fam == FamilyAny { + m[IPv4] = append(m[IPv4], r) + m[IPv6] = append(m[IPv6], r) + } else { + m[fam] = append(m[fam], r) + } + } + return m + } + + // A backup taken on a dual-stack host and replayed onto an IPv4-only one has + // no v6 file to splice its IPv6 rows into. Report what is being dropped rather + // than failing the whole restore: the IPv4 half is still worth applying, and + // the host genuinely cannot hold the rest. + if !f.managesIPv6() { + // Count only rows pinned to IPv6: a family-agnostic row is not dropped, + // it narrows to the IPv4 file like every other FamilyAny write. + dropped := 0 + for _, r := range backup.Rules { + if r.impliedFamily() == IPv6 { + dropped++ + } + } + for _, r := range backup.NATRules { + if r.impliedFamily() == IPv6 { + dropped++ + } + } + if dropped > 0 { + log.Printf("firewall: iptables restore skipped %d IPv6 rule(s); this host does not manage IPv6", dropped) + } + } + + for _, path := range f.savePaths() { + fam := f.fileFamily(path) + + // Marshal only the rule (-A) lines; rewriteFilterRules/rewriteNATRules splice + // them into the existing save file, replacing the managed chains' rules while + // preserving chain-policy lines, user-defined chains, and the *mangle/*raw + // tables that Backup never captures. A from-scratch scaffold would silently + // reset a DROP policy to ACCEPT and delete every unmanaged rule. + var ruleLines []string + for _, r := range groupRules()[fam] { + c := *r + if c.Family == FamilyAny { + c.Family = fam + } + // A TCPUDP rule has no single-line iptables form; fan it out into a tcp + // row and a udp row before marshalling. + for _, sub := range expandProtocols(&c) { + rl, err := f.marshalRuleLines(sub) + if err != nil { + return err + } + ruleLines = append(ruleLines, rl...) + } + } + + var natLines []string + for _, r := range groupNAT()[fam] { + c := *r + if c.Family == FamilyAny { + c.Family = fam + } + if err := c.validate(); err != nil { + return err + } + rl, err := f.MarshalNATRule(&c) + if err != nil { + return err + } + natLines = append(natLines, rl) + } + + if err := f.rewriteFilterRules(path, ruleLines); err != nil { + return err + } + if err := f.rewriteNATRules(path, natLines); err != nil { + return err + } + } + + // Re-assert the captured filter chain policies last, so a restore onto a host + // whose default policy differs (e.g. a fresh ACCEPT host) reproduces the backed- + // up policy rather than silently inheriting the current one. + return applyBackupPolicy(ctx, f, zoneName, backup) +} + +// Reload activates the staged state: the address sets first, so a rule matching +// on one resolves when the save files load, then the restore service(s) for the +// rules, then the kernel-side destroy of any set removed this session, which has +// to wait until the rules that referenced it are gone. +// +// The sets are loaded here directly rather than by restarting IPSetService, +// whose stop hook varies by packaging — some flush, some save the live state +// back over the file — and would put this backend's staged file at the mercy of +// it. The service still matters for boot, which is why writeStagedSets enables it. +func (f *IPTables) Reload(ctx context.Context) error { + if err := f.applyStagedSets(ctx); err != nil { + return err + } + + if err := restartService(ctx, f.IP4Service); err != nil { + return err + } + + // Nothing more to restart when the host has no v6 service, or when the Debian + // layout's single service (netfilter-persistent) already restored both + // families above. + if f.IP6Service != "" && f.IP6Service != f.IP4Service { + if err := restartService(ctx, f.IP6Service); err != nil { + return err + } + } + + return f.applyPendingSetRemovals(ctx) +} + +// Close releases manager resources. +func (f *IPTables) Close(ctx context.Context) error { + return nil +} + +// coalesceLoggedRules merges each LOG-only rule that is immediately followed by +// a matching action rule into a single logical rule with Log set. An orphan LOG +// rule (no matching action after it) is dropped. +func coalesceLoggedRules(rules []*Rule) []*Rule { + out := make([]*Rule, 0, len(rules)) + for i := 0; i < len(rules); i++ { + cur := rules[i] + if cur.Action == ActionInvalid && cur.Log { + // A LOG-only line: fold it into the next line if that line is its + // action partner, else drop this orphan LOG line. + var next *Rule + if i+1 < len(rules) { + next = rules[i+1] + } + if logPartner(cur, next) { + out = append(out, mergeLogPair(cur, next)) + i++ + } + continue + } + out = append(out, cur) + } + return out +} diff --git a/iptables_linux_test.go b/iptables_linux_test.go new file mode 100644 index 0000000..1f3dd06 --- /dev/null +++ b/iptables_linux_test.go @@ -0,0 +1,1534 @@ +package firewall + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/vishvananda/netlink" +) + +func TestIPTablesRules(t *testing.T) { + fw := new(IPTables) + + // Parse a rule that is expected to parse right. + rule, err := fw.UnmarshalRule(`-A INPUT -s 192.168.0.0/24 -p udp -m udp --dport 23 -j ACCEPT`, IPv4) + require.NoError(t, err) + + // Re-encode the rule which should result in expected rich rule. + richRule, err := fw.MarshalRule(rule) + require.NoError(t, err) + require.Equal(t, `-A INPUT -s 192.168.0.0/24 -p udp -m udp --dport 23 -j ACCEPT`, richRule, + "the rich rule did not encode as expected") + + // Try encoding a bunch of invalid rules. + invalidRules := []string{ + `-A TEST -s 192.168.0.0/24 -j DROP`, + `-s 192.168.0.0/24 -p udp -m udp --dport 23 -j ACCEPT`, + `-A INPUT -s 192.168.0.0/24 -p udp -m udp --dport 23`, + `-A INPUT -s 192.168.0.0/24 -p udp -m udp --dport 23 -j MARK`, + `-A INPUT -s 192.168.0.0/24 -p tcp -m udp --dport 23 -j DROP`, + } + for _, richRule := range invalidRules { + _, err := fw.UnmarshalRule(richRule, IPv4) + require.Error(t, err, "this rich rule was parsed when it should be invalid: %s", richRule) + } + + // Test rules we typically set. + validRules := []string{ + `-A INPUT -p udp -m udp --dport 4789 -j ACCEPT`, + `-A OUTPUT -p udp -m udp --dport 4789 -j ACCEPT`, + `-A INPUT -s 203.0.113.10 -p tcp -m tcp --dport 4789 -j ACCEPT`, + `-A OUTPUT -d 203.0.113.10 -p tcp -m tcp --dport 4791 -j ACCEPT`, + } + 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) + } + + // A port without a concrete protocol cannot be expressed in iptables + // (`-m tcp/udp --dport` is invalid without `-p tcp/udp`), so validateRule + // must reject it before the encoder emits an invalid rule. + require.Error(t, fw.validateRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}), + "expected a port with no protocol to be rejected") +} + +func TestIPTablesFeatureRules(t *testing.T) { + fw := new(IPTables) + + // Confirm representative encodings. + cases := []struct { + rule *Rule + want string + }{ + {&Rule{Proto: ICMP, Action: Reject}, "-A INPUT -p icmp -j REJECT"}, + {&Rule{Proto: ICMPv6, Action: Accept}, "-A INPUT -p icmpv6 -j ACCEPT"}, + {&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, "-A INPUT -p icmp -m icmp --icmp-type 8 -j ACCEPT"}, + {&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, "-A INPUT -p icmpv6 -m icmp6 --icmpv6-type 128 -j ACCEPT"}, + {&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, "-A INPUT -p tcp -m multiport --dports 80,443 -j ACCEPT"}, + {&Rule{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, "-A INPUT -p udp -m multiport --dports 1000:2000 -j ACCEPT"}, + {&Rule{Proto: TCP, Port: 22, State: StateNew | StateEstablished, Action: Accept}, "-A INPUT -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT"}, + {&Rule{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, "-A INPUT -i eth0 -p tcp -m tcp --dport 22 -j ACCEPT"}, + {&Rule{Direction: DirOutput, OutInterface: "eth1", Action: Drop}, "-A OUTPUT -o eth1 -j DROP"}, + } + 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: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}, {Start: 8000, End: 8100}}, Action: Accept}, + {Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, + {Proto: TCP, Port: 22, State: StateEstablished | StateRelated, Action: Accept}, + {Source: "10.0.0.0/8", Proto: TCP, Port: 22, State: StateNew, Action: Accept}, + {InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, + {Direction: DirOutput, OutInterface: "eth1", Proto: UDP, Port: 53, Action: Accept}, + {Proto: ICMP, Action: Accept}, + {Proto: ICMPv6, Action: Accept}, + {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, + {Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept}, + } + for _, r := range rules { + spec, err := fw.MarshalRule(r) + require.NoError(t, err, "failed to marshal %+v", *r) + + parsed, err := fw.UnmarshalRule(spec, r.Family) + require.NoError(t, err, "failed to parse %q", spec) + require.True(t, parsed.Equal(r, true), + "round-trip mismatch: input %+v, spec %q, output %+v", *r, spec, parsed) + } + + // Bare `--icmp-type ` and `--dport`/`--sport` (without an explicit `-m`), + // as used in ufw's iptables rules files, must parse. + icmpRule, err := fw.UnmarshalRule("-A INPUT -p icmp --icmp-type echo-request -j ACCEPT", IPv4) + require.NoError(t, err) + require.Equal(t, ICMP, icmpRule.Proto, "unexpected bare icmp-type parse: %+v", *icmpRule) + require.NotNil(t, icmpRule.ICMPType, "unexpected bare icmp-type parse: %+v", *icmpRule) + require.EqualValues(t, 8, *icmpRule.ICMPType, "unexpected bare icmp-type parse: %+v", *icmpRule) + + dportRule, err := fw.UnmarshalRule("-A INPUT -p udp --dport 5353 -j ACCEPT", IPv4) + require.NoError(t, err) + require.Equal(t, UDP, dportRule.Proto, "unexpected bare dport parse: %+v", *dportRule) + require.EqualValues(t, 5353, dportRule.Port, "unexpected bare dport parse: %+v", *dportRule) + + sportRule, err := fw.UnmarshalRule("-A INPUT -p tcp --sport 1234 -j ACCEPT", IPv4) + require.NoError(t, err) + require.Equal(t, TCP, sportRule.Proto, "unexpected bare sport parse: %+v", *sportRule) + require.EqualValues(t, 1234, sportRule.SourcePort, "unexpected bare sport parse: %+v", *sportRule) + + // The legacy `-m state --state` match must also parse. + r, err := fw.UnmarshalRule("-A INPUT -p tcp -m tcp --dport 22 -m state --state NEW,ESTABLISHED -j ACCEPT", IPv4) + require.NoError(t, err) + require.Equal(t, StateNew|StateEstablished, r.State, "unexpected state parse") +} + +// An iptables-save line may carry a leading [pkts:bytes] counter prefix; the +// parser must capture it onto the rule and keep parsing the rest of the line. +func TestIPTablesCounterPrefix(t *testing.T) { + fw := new(IPTables) + r, err := fw.UnmarshalRule("[42:3360] -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", IPv4) + require.NoError(t, err) + require.Equal(t, uint64(42), r.Packets, "packet counter not captured") + require.Equal(t, uint64(3360), r.Bytes, "byte counter not captured") + require.True(t, r.EqualBase(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, true), + "counters must not be part of rule identity: %+v", r) + + // A line without a counter prefix still parses, with zero counters. + r2, err := fw.UnmarshalRule("-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", IPv4) + require.NoError(t, err) + require.Zero(t, r2.Packets) + require.Zero(t, r2.Bytes) +} + +// A nat rule in the OUTPUT chain (locally-generated DNAT) cannot be represented +// distinctly by the NATRule model, so it must be treated as foreign: skipped on +// read (never surfaced as a PREROUTING DNAT) and preserved verbatim by +// rewriteNATRules (never relocated to PREROUTING or dropped on Restore). +func TestIPTablesNATOutputChainPreserved(t *testing.T) { + // UnmarshalNATRule rejects an OUTPUT-chain rule so natRulesInFile skips it. + f := new(IPTables) + _, err := f.UnmarshalNATRule("-A OUTPUT -p tcp -m tcp --dport 81 -j DNAT --to-destination 10.0.0.6", IPv4) + require.Error(t, err, "an OUTPUT-chain nat rule must not be surfaced as a managed NAT rule") + + dir := t.TempDir() + p4 := filepath.Join(dir, "iptables") + save := "*nat\n" + + ":PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n" + + "-A PREROUTING -p tcp -m tcp --dport 80 -j DNAT --to-destination 10.0.0.5\n" + + "-A OUTPUT -p tcp -m tcp --dport 81 -j DNAT --to-destination 10.0.0.6\n" + + "COMMIT\n" + require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: filepath.Join(dir, "ip6tables")} + + // GetNATRules-side read surfaces only the PREROUTING rule; the OUTPUT rule is skipped. + got, err := fw.natRulesInFile(p4) + require.NoError(t, err) + require.Len(t, got, 1, "only the PREROUTING nat rule should be surfaced, not the OUTPUT one") + require.EqualValues(t, 80, got[0].Port) + + // Restore-side rewrite replaces the managed (PREROUTING) rules but preserves the + // foreign OUTPUT rule verbatim. + require.NoError(t, fw.rewriteNATRules(p4, []string{"-A PREROUTING -p tcp -m tcp --dport 90 -j DNAT --to-destination 10.0.0.9"})) + data, err := os.ReadFile(p4) + require.NoError(t, err) + require.Contains(t, string(data), "-A OUTPUT -p tcp -m tcp --dport 81 -j DNAT --to-destination 10.0.0.6", + "the OUTPUT nat rule must be preserved verbatim, not relocated or dropped") + require.Contains(t, string(data), "--dport 90", "the rewritten PREROUTING rule must be present") + require.NotContains(t, string(data), "--dport 80", "the old managed PREROUTING rule must be replaced") +} + +// TestIPTablesAddRulePreservesUnmodeledRule verifies an additive AddRule keeps a +// pre-existing INPUT/OUTPUT rule the parser cannot model (here an -m recent +// rate-limit rule). GetRules cannot represent such a rule, so a rewrite that +// dropped it would silently delete a foreign rule the read-modify-write add must +// keep. +func TestIPTablesAddRulePreservesUnmodeledRule(t *testing.T) { + dir := t.TempDir() + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + recent := "-A INPUT -p tcp -m tcp --dport 22 -m recent --update --seconds 60 --hitcount 4 -j DROP" + orphanLog := "-A INPUT -p udp -m udp --dport 53 -j LOG --log-prefix \"dns: \"" + save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n" + + recent + "\n" + + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + + orphanLog + "\n" + + "-A FORWARD -p tcp -m tcp --dport 8080 -j ACCEPT\n" + + "COMMIT\n" + require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) + require.NoError(t, os.WriteFile(p6, []byte("*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n"), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // The -m recent rule and the orphan LOG are not modeled, so GetRules cannot see + // them; only the two modeled rules surface: the INPUT dport 80 and the FORWARD + // dport 8080, the forward chain being modeled too. + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 2, "only the two modeled rules should surface") + + // Additively add a new rule. + require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept})) + + data, err := os.ReadFile(p4) + require.NoError(t, err) + got := string(data) + require.Contains(t, got, recent, "the foreign -m recent rule must be preserved by an additive add") + require.Contains(t, got, orphanLog, "the standalone LOG rule must be preserved") + require.Contains(t, got, "-A FORWARD -p tcp -m tcp --dport 8080 -j ACCEPT", "the FORWARD rule must be preserved") + require.Contains(t, got, "--dport 443", "the newly added rule must be present") + require.Contains(t, got, "--dport 80", "the pre-existing modeled rule must be preserved") +} + +// A comment containing a backslash, an embedded double-quote or a non-ASCII +// rune must round-trip byte-for-byte: strconv.Quote (the previous encoding) +// renders these as Go string-literal escapes that shlex.Split — the parser +// GetRules reads such a line back with — does not interpret, so the comment +// came back mangled (e.g. a literal tab as a two-character "\t") and never +// compared equal to the desired rule, so Sync churned on it forever. +func TestIPTablesCommentSpecialCharsRoundTrip(t *testing.T) { + dir := t.TempDir() + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + cases := []struct { + port uint16 + comment string + }{ + {22, `back\slash`}, + {23, `quote"inside`}, + {24, "tab\ttab"}, + {25, "unicode ключ"}, + } + for _, c := range cases { + require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: c.port, Action: Accept, Comment: c.comment})) + } + + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + byPort := map[uint16]*Rule{} + for _, r := range rules { + byPort[r.Port] = r + } + for _, c := range cases { + require.Equal(t, c.comment, byPort[c.port].Comment, "comment %q must round-trip unchanged", c.comment) + } + + // A literal newline cannot be expressed (it would split the rules file's + // one-line-per-rule format), so it is rejected rather than silently mangled. + err = fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 26, Action: Accept, Comment: "line1\nline2"}) + require.Error(t, err, "a comment containing a newline must be rejected") +} + +// A user comment that itself begins with the configured prefix must survive the +// round-trip intact: GetRules strips the prefix exactly once, so a comment of +// "myapp is great" (stored as "myapp myapp is great") must read back whole and +// not be truncated to "is great". +func TestIPTablesCommentBeginningWithPrefix(t *testing.T) { + dir := t.TempDir() + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"} + ctx := context.Background() + + require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 8080, Action: Accept, Comment: "myapp is great"})) + + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 1) + require.Equal(t, "myapp is great", rules[0].Comment, "prefix must be stripped exactly once") + require.True(t, rules[0].HasPrefix) +} + +// A single contiguous port range on a `-m tcp/udp/sctp --dport` — the form +// iptables-save emits and ufw's before.rules use — must parse. The module-match +// handlers must accept the range form, so GetRules reports the rule rather than +// dropping it (and re-adding a duplicate every reconcile). +func TestIPTablesModulePortRangeParse(t *testing.T) { + cases := []struct { + spec string + src bool + }{ + {`-A INPUT -p tcp -m tcp --dport 1000:2000 -j ACCEPT`, false}, + {`-A INPUT -p tcp -m tcp --sport 1000:2000 -j ACCEPT`, true}, + {`-A INPUT -p udp -m udp --dport 1000:2000 -j ACCEPT`, false}, + {`-A INPUT -p sctp -m sctp --dport 1000:2000 -j ACCEPT`, false}, + } + for _, c := range cases { + r, err := unmarshalIPTablesRule(c.spec, IPv4) + require.NoError(t, err, "range on module match must parse: %s", c.spec) + specs := r.PortSpecs() + if c.src { + specs = r.SourcePortSpecs() + } + require.Equal(t, []PortRange{{Start: 1000, End: 2000}}, specs, "range not captured: %s", c.spec) + } +} + +// iptables applies and prints a default --limit-burst 5 on every -m limit match. +// A rule added with Burst 0 must still compare equal to the one iptables-save +// lists back with burst 5 (mirrors the nft burst-5 normalization). +func TestIPTablesRateBurstDefaultNormalized(t *testing.T) { + orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}} + saved := `-A INPUT -p tcp -m tcp --dport 22 -m limit --limit 10/min --limit-burst 5 -j ACCEPT` + got, err := unmarshalIPTablesRule(saved, IPv4) + require.NoError(t, err) + require.NotNil(t, got.RateLimit) + require.Equal(t, uint(0), got.RateLimit.Burst, "iptables' default burst of 5 must normalize to the unset 0") + require.True(t, got.EqualBase(orig, true), "a burst-5 read-back must equal the burst-0 original") +} + +// A DNAT rule on SCTP (which carries ports) must round-trip: the `-m sctp +// --dport` match iptables emits must parse back. +func TestIPTablesSCTPNATRoundTrip(t *testing.T) { + f := &IPTables{rulePrefix: "myapp"} + orig := &NATRule{Kind: DNAT, Family: IPv4, Proto: SCTP, Port: 132, ToAddress: "10.0.0.5", ToPort: 132} + spec, err := f.MarshalNATRule(orig) + require.NoError(t, err) + got, err := f.UnmarshalNATRule(spec, IPv4) + require.NoError(t, err) + require.True(t, orig.EqualBase(got), "sctp nat rule must round-trip; got %+v", got) +} + +// iptables labels an ICMPv6 type with an ICMPv6 name, several of which mean a +// different number under ICMPv4 (echo-request is 128 vs 8, destination-unreachable +// is 1 vs 3). The `--icmpv6-type`/`-m icmp6` flags must resolve names through the +// ICMPv6 table, not the ICMPv4 one. Library-written rules emit the number, so the +// round-trip tests never exercised the name path — but ufw's before6.rules do. +func TestIPTablesICMPv6TypeNameParse(t *testing.T) { + // -m icmp6 module form. + r, err := unmarshalIPTablesRule("-A INPUT -p ipv6-icmp -m icmp6 --icmpv6-type echo-request -j ACCEPT", IPv6) + require.NoError(t, err) + require.NotNil(t, r.ICMPType) + require.Equal(t, uint8(128), *r.ICMPType, "icmpv6 echo-request is type 128") + + // Bare --icmpv6-type form (no -m icmp6). + r2, err := unmarshalIPTablesRule("-A INPUT -p ipv6-icmp --icmpv6-type destination-unreachable -j ACCEPT", IPv6) + require.NoError(t, err) + require.NotNil(t, r2.ICMPType) + require.Equal(t, uint8(1), *r2.ICMPType, "icmpv6 destination-unreachable is type 1") + + // The ICMPv4 name path must be unchanged. + r3, err := unmarshalIPTablesRule("-A INPUT -p icmp --icmp-type echo-request -j ACCEPT", IPv4) + require.NoError(t, err) + require.NotNil(t, r3.ICMPType) + require.Equal(t, uint8(8), *r3.ICMPType, "icmp echo-request is type 8") +} + +// GetDefaultPolicy reads both family save files: it returns the shared policy +// when they agree and errors when they diverge, rather than silently reporting +// only the IPv4 policy. +func TestIPTablesGetDefaultPolicyBothFamilies(t *testing.T) { + dir := t.TempDir() + write := func(name, in string) string { + p := filepath.Join(dir, name) + body := "*filter\n:INPUT " + in + " [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" + require.NoError(t, os.WriteFile(p, []byte(body), 0644)) + return p + } + ctx := context.Background() + + // Agreement: both files DROP input -> policy reports DROP. + f := &IPTables{IP4Path: write("iptables", "DROP"), IP6Path: write("ip6tables", "DROP")} + pol, err := f.GetDefaultPolicy(ctx, "") + require.NoError(t, err) + require.Equal(t, Drop, pol.Input) + + // Divergence: v4 ACCEPT, v6 DROP -> error, because there is no single policy. + f = &IPTables{IP4Path: write("iptables2", "ACCEPT"), IP6Path: write("ip6tables2", "DROP")} + _, err = f.GetDefaultPolicy(ctx, "") + require.Error(t, err, "a v4/v6 policy mismatch must be surfaced, not hidden") +} + +// iptables Backup captures only the INPUT/OUTPUT filter rules and the nat rules, +// so Restore must splice those back into the existing save file and leave +// everything it did not capture untouched: chain default policies, the FORWARD +// chain, user-defined chains, and the *mangle/*raw tables. The old scaffold-based +// Restore silently reset a DROP policy to ACCEPT and deleted all of that. +func TestIPTablesRestorePreservesUnmanaged(t *testing.T) { + dir := t.TempDir() + // A realistic save file: hardened DROP policies, a FORWARD rule, a *mangle + // table, and a *nat table with a managed DNAT plus a foreign DOCKER chain. + save := "*mangle\n:PREROUTING ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n" + + "-A PREROUTING -j MARK --set-mark 1\nCOMMIT\n" + + "*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n:DOCKER - [0:0]\n" + + "-A PREROUTING -p tcp --dport 80 -j DNAT --to-destination 10.0.0.5:8080\n-A DOCKER -j RETURN\nCOMMIT\n" + + "*filter\n:INPUT DROP [0:0]\n:OUTPUT DROP [0:0]\n:FORWARD DROP [0:0]\n" + + "-A INPUT -p tcp --dport 22 -j ACCEPT\n-A FORWARD -s 10.0.0.0/8 -j ACCEPT\nCOMMIT\n" + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + // A staging path keeps Backup's address-set read on the file rather than the + // live kernel, which an unprivileged test cannot query. + f := &IPTables{IP4Path: p4, IP6Path: p6, IPSetPath: filepath.Join(dir, "ipsets")} + ctx := context.Background() + + backup, err := f.Backup(ctx, "") + require.NoError(t, err) + require.NoError(t, f.Restore(ctx, "", backup)) + + out, err := os.ReadFile(p4) + require.NoError(t, err) + got := string(out) + + // Policies and unmanaged content Backup never captured must survive. + require.Contains(t, got, ":INPUT DROP", "the INPUT DROP policy must not flip to ACCEPT") + require.Contains(t, got, ":FORWARD DROP", "the FORWARD DROP policy must survive") + require.Contains(t, got, "-A FORWARD -s 10.0.0.0/8 -j ACCEPT", "the FORWARD rule must survive") + require.Contains(t, got, "*mangle", "the mangle table must survive") + require.Contains(t, got, "MARK --set-mark 1", "the mangle rule must survive") + require.Contains(t, got, ":DOCKER", "the foreign nat chain must survive") + require.Contains(t, got, "-A DOCKER -j RETURN", "the foreign nat chain's rule must survive") + + // The managed rules Backup captured must be re-applied. + require.Contains(t, got, "--dport 22", "the managed INPUT rule must be restored") + require.Contains(t, got, "DNAT", "the managed nat rule must be restored") + + // Restore must be idempotent: a second Backup/Restore reproduces the same file. + backup2, err := f.Backup(ctx, "") + require.NoError(t, err) + require.NoError(t, f.Restore(ctx, "", backup2)) + out2, err := os.ReadFile(p4) + require.NoError(t, err) + require.Equal(t, got, string(out2), "Restore must be idempotent") +} + +// TestIPTablesInsertForeignChainPosition guards the InsertRule position counting +// against a foreign chain whose name merely starts with the target chain name +// (e.g. firewalld's "INPUT_direct"). GetRules numbers only exact INPUT/OUTPUT +// rules, so the insert path must count the same way. With the prefix-match bug +// the foreign line is miscounted and the new rule lands one slot too early. +func TestIPTablesInsertForeignChainPosition(t *testing.T) { + dir := t.TempDir() + // A foreign INPUT_direct chain precedes two managed INPUT rules. GetRules + // reports the INPUT rules as #1 (dport 22) and #2 (dport 80); the + // INPUT_direct line is not an INPUT rule and must not be counted. + save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + + "-A INPUT_direct -j DROP\n" + + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + + "COMMIT\n" + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // Insert at INPUT position 2, i.e. between dport 22 (#1) and dport 80 (#2). + require.NoError(t, fw.InsertRule(ctx, "", 2, &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept})) + + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + var got *Rule + for _, r := range rules { + if r.Port == 443 { + got = r + } + } + require.NotNil(t, got, "the inserted rule should be present after InsertRule") + require.Equal(t, 2, got.Number, + "the rule must land at INPUT position 2, not be miscounted past the foreign INPUT_direct chain") +} + +// TestIPTablesMoveForeignChainPosition is the MoveRule analogue: moving a rule to +// a 1-based position must count only exact INPUT rules, ignoring a foreign chain +// whose name starts with INPUT. +func TestIPTablesMoveForeignChainPosition(t *testing.T) { + dir := t.TempDir() + // INPUT rules on read: #1 dport 22, #2 dport 80, #3 dport 443. Move dport 443 + // to position 1; it must become #1 with 22 and 80 shifting down. + save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + + "-A INPUT_direct -j DROP\n" + + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + + "-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT\n" + + "COMMIT\n" + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + require.NoError(t, fw.MoveRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept}, 1)) + + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + byPort := map[uint16]int{} + for _, r := range rules { + byPort[r.Port] = r.Number + } + require.Equal(t, 1, byPort[443], "moved rule must land at INPUT position 1, ahead of the foreign chain miscount") + require.Equal(t, 2, byPort[22], "the displaced first rule shifts to position 2") + require.Equal(t, 3, byPort[80], "the displaced second rule shifts to position 3") +} + +// scanSaveGroups numbers each chain's groups the way GetRules does, and the +// InsertRule/MoveRule position math reads those numbers straight off the same +// scan — so a divergence here breaks Rule.Number's promise to mirror the position +// argument. It pins the four cases that decide a number: a LOG line paired with +// the action line directly under it is one logical rule carrying both lines, an +// orphan LOG line resolves to no rule, a line the parser rejects resolves to no +// rule, and an intervening line of either kind breaks a pair that would otherwise +// have formed. Chains are numbered independently. +func TestIPTablesScanSaveGroupsNumbering(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "iptables.save") + content := strings.Join([]string{ + "*filter", + ":INPUT ACCEPT [0:0]", + ":OUTPUT ACCEPT [0:0]", + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", + // A bare LOG line whose match fields agree with nothing below it. + `-A INPUT -j LOG --log-prefix "audit "`, + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT", + // A LOG line and the action line directly under it: one logged rule. + `-A INPUT -p tcp -m tcp --dport 443 -j LOG --log-prefix "https "`, + "-A INPUT -p tcp -m tcp --dport 443 -j ACCEPT", + // A foreign line the model cannot hold, wedged between a LOG line and the + // action it would otherwise have paired with. + `-A INPUT -p tcp -m tcp --dport 8443 -j LOG --log-prefix "alt "`, + "-A INPUT -p tcp -m tcp --tcp-flags SYN,ACK SYN -j DROP", + "-A INPUT -p tcp -m tcp --dport 8443 -j ACCEPT", + "-A OUTPUT -p tcp -m tcp --dport 25 -j ACCEPT", + "COMMIT", + "", + }, "\n") + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + + type got struct { + chain string + number int + resolve bool // The group resolved to a rule GetRules would report. + lines int + } + var seen []got + f := &IPTables{} + require.NoError(t, f.scanSaveFile(path, IPv4, func(g iptGroup) error { + if g.chain != "" { + seen = append(seen, got{g.chain, g.number, g.rule != nil, len(g.raw)}) + } + return nil + })) + + require.Equal(t, []got{ + {"INPUT", 1, true, 1}, // dport 22 + {"INPUT", 0, false, 1}, // orphan LOG: no rule, no number + {"INPUT", 2, true, 1}, // dport 80 + {"INPUT", 3, true, 2}, // the logged pair, both lines in one group + {"INPUT", 0, false, 1}, // LOG line the foreign line below orphaned + {"INPUT", 0, false, 1}, // the unmodeled --tcp-flags line + {"INPUT", 4, true, 1}, // dport 8443, its LOG partnership broken + {"OUTPUT", 1, true, 1}, // OUTPUT numbers from 1 independently + }, seen) +} + +// TestIPTablesInsertPastConsecutiveOrphanLogs pins the walker fix: two foreign +// orphan LOG lines (no matching action) that GetRules drops must not shift the +// 1-based insert position. The old stateful walker swallowed the line after each +// LOG line, so with two consecutive orphan LOGs an insert drifted one position. +func TestIPTablesInsertPastConsecutiveOrphanLogs(t *testing.T) { + dir := t.TempDir() + save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + + "-A INPUT -p udp -m udp --dport 53 -j LOG --log-prefix \"dns: \"\n" + + "-A INPUT -p udp -m udp --dport 123 -j LOG --log-prefix \"ntp: \"\n" + + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + + "COMMIT\n" + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // GetRules reports #1 dport 22, #2 dport 80 (the orphan LOGs are not rules). + // Insert at position 2, i.e. between them. + require.NoError(t, fw.InsertRule(ctx, "", 2, &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept})) + + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + byPort := map[uint16]int{} + for _, r := range rules { + byPort[r.Port] = r.Number + } + require.Equal(t, 1, byPort[22], "the first rule stays at position 1") + require.Equal(t, 2, byPort[443], "inserted rule must land at INPUT position 2, past the two orphan LOG lines") + require.Equal(t, 3, byPort[80], "the displaced second rule shifts to position 3") + + // The foreign orphan LOG lines must survive the insert. + got, err := os.ReadFile(p4) + require.NoError(t, err) + require.Contains(t, string(got), "dns: ", "orphan LOG line must be preserved") + require.Contains(t, string(got), "ntp: ", "orphan LOG line must be preserved") +} + +// TestIPTablesMovePastConsecutiveOrphanLogs is the MoveRule analogue: moving a +// rule to a position after two orphan LOG lines must count only logical rules. +func TestIPTablesMovePastConsecutiveOrphanLogs(t *testing.T) { + dir := t.TempDir() + save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + + "-A INPUT -p udp -m udp --dport 53 -j LOG --log-prefix \"dns: \"\n" + + "-A INPUT -p udp -m udp --dport 123 -j LOG --log-prefix \"ntp: \"\n" + + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + + "COMMIT\n" + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // Move dport 22 (currently #1) to position 2; it must end up #2 with dport 80 + // at #1, not be miscounted onto the orphan LOG lines. + require.NoError(t, fw.MoveRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, 2)) + + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + byPort := map[uint16]int{} + for _, r := range rules { + byPort[r.Port] = r.Number + } + require.Equal(t, 1, byPort[80], "the displaced second rule becomes position 1") + require.Equal(t, 2, byPort[22], "the moved rule must land at position 2, past the two orphan LOG lines") +} + +// A counter-annotated (iptables-save -c) nat line must parse, matching the +// filter parser. The nat parser must strip the leading [pkts:bytes] prefix, so +// a counter-annotated save file's NAT rules survive in GetNATRules/Backup and +// can be removed. +func TestIPTablesNATCounterPrefix(t *testing.T) { + fw := new(IPTables) + plain := `-A PREROUTING -p tcp -m tcp --dport 80 -j DNAT --to-destination 10.0.0.1` + counter := `[0:0] -A PREROUTING -p tcp -m tcp --dport 80 -j DNAT --to-destination 10.0.0.1` + + rp, err := fw.UnmarshalNATRule(plain, IPv4) + require.NoError(t, err) + rc, err := fw.UnmarshalNATRule(counter, IPv4) + require.NoError(t, err, "counter-prefixed NAT rule should parse") + require.True(t, rp.EqualBase(rc), "counter-prefixed NAT rule should equal the plain one") +} + +// A "replace" must remove a pre-existing counter-annotated filter rule. The +// filter parser reads such lines (populating Packets/Bytes), so the file-rewrite +// path must recognise them as rules too — otherwise a Sync-based replace or +// Restore leaves stale foreign rules behind. +func TestIPTablesReplaceStripsCounterRules(t *testing.T) { + dir := t.TempDir() + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + v4 := strings.Join([]string{ + "*filter", + ":INPUT ACCEPT [0:0]", + ":OUTPUT ACCEPT [0:0]", + ":FORWARD ACCEPT [0:0]", + "[7:420] -A INPUT -p tcp -m tcp --dport 9999 -j ACCEPT", + "COMMIT", + "", + }, "\n") + require.NoError(t, os.WriteFile(p4, []byte(v4), 0o644)) + require.NoError(t, os.WriteFile(p6, []byte("*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n"), 0o644)) + + f := &IPTables{IP4Path: p4, IP6Path: p6} + _, _, err := Sync(context.Background(), f, "", []*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}}) + require.NoError(t, err) + + got, err := os.ReadFile(p4) + require.NoError(t, err) + require.NotContains(t, string(got), "9999", "stale counter-annotated rule must be removed by replace; file:\n%s", got) + require.Contains(t, string(got), "--dport 22", "desired rule must be present") +} + +// A reconcile must preserve rules in chains the library does not model. A rule in +// a user-defined chain (CUSTOM) is never surfaced by GetRules, so the file-rewrite +// add and remove paths must keep it verbatim. The FORWARD chain, by contrast, is a +// modeled direction: GetRules surfaces its rules and a Sync-based reconcile manages +// them like INPUT/OUTPUT. +func TestIPTablesManagesForwardRules(t *testing.T) { + dir := t.TempDir() + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + v4 := strings.Join([]string{ + "*filter", + ":INPUT ACCEPT [0:0]", + ":OUTPUT ACCEPT [0:0]", + ":FORWARD ACCEPT [0:0]", + "-A FORWARD -s 10.0.0.0/8 -j ACCEPT", + "-A INPUT -p tcp -m tcp --dport 9999 -j ACCEPT", + "-A CUSTOM -j ACCEPT", + "COMMIT", + "", + }, "\n") + require.NoError(t, os.WriteFile(p4, []byte(v4), 0o644)) + require.NoError(t, os.WriteFile(p6, []byte("*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n"), 0o644)) + + f := &IPTables{IP4Path: p4, IP6Path: p6} + + // The FORWARD rule now surfaces as a modeled forward-direction rule. + rules, err := f.GetRules(context.Background(), "") + require.NoError(t, err) + fwd := &Rule{Direction: DirForward, Family: IPv4, Source: "10.0.0.0/8", Action: Accept} + found := false + for _, r := range rules { + if r.Equal(fwd, true) { + found = true + } + } + require.True(t, found, "the FORWARD rule should be modeled; got %+v", rules) + + // Additive add: existing rules survive. + require.NoError(t, f.AddRule(context.Background(), "", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept})) + got, err := os.ReadFile(p4) + require.NoError(t, err) + require.Contains(t, string(got), "-A FORWARD -s 10.0.0.0/8 -j ACCEPT", "an additive add keeps existing FORWARD rules; file:\n%s", got) + require.Contains(t, string(got), "--dport 22", "desired rule must be present") + + // Full replace via Sync: every modeled chain — INPUT, OUTPUT and FORWARD — is + // reconciled to exactly the desired set, so the unlisted FORWARD and INPUT rules + // are removed and a desired FORWARD rule is added; a rule in a user-defined chain + // is still preserved verbatim. + desired := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, + {Direction: DirForward, Family: IPv4, Source: "192.168.0.0/16", Action: Accept}, + } + _, _, err = Sync(context.Background(), f, "", desired) + require.NoError(t, err) + got, err = os.ReadFile(p4) + require.NoError(t, err) + require.NotContains(t, string(got), "10.0.0.0/8", "the replace reconciles away the unwanted FORWARD rule; file:\n%s", got) + require.NotContains(t, string(got), "9999", "the replace reconciles away the unwanted INPUT rule; file:\n%s", got) + require.Contains(t, string(got), "-A FORWARD -s 192.168.0.0/16 -j ACCEPT", "the desired FORWARD rule is added; file:\n%s", got) + require.Contains(t, string(got), "--dport 22", "desired input rule present") + require.Contains(t, string(got), "-A CUSTOM -j ACCEPT", "a user-defined chain rule is preserved; file:\n%s", got) +} + +// A negated match in a -m udp/sctp block must not be mis-parsed. Grouping "!" +// with --source-port consumed the following token and parsed the option name as a +// port; the loop must reject the negation like the tcp loop and still parse a +// normal udp source/destination port. +func TestIPTablesUDPNegationParse(t *testing.T) { + fw := new(IPTables) + + // A normal udp source-port rule still round-trips. + got, err := fw.UnmarshalRule("-A INPUT -p udp -m udp --sport 53 -j ACCEPT", IPv4) + require.NoError(t, err) + require.Equal(t, uint16(53), got.SourcePort) + + // A negated port match cannot be represented and must be rejected cleanly. + _, err = fw.UnmarshalRule("-A INPUT -p udp -m udp ! --dport 80 -j ACCEPT", IPv4) + require.Error(t, err, "a negated udp port match must be rejected, not mis-parsed") +} + +// An additive add must preserve a standalone LOG rule (no terminal action). Such a +// rule cannot be modeled as a Rule, so GetRules drops it; if the file-rewrite path +// also drops it, an AddRule silently deletes a foreign audit-log rule. +func TestIPTablesAddPreservesOrphanLog(t *testing.T) { + dir := t.TempDir() + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + v4 := strings.Join([]string{ + "*filter", + ":INPUT ACCEPT [0:0]", + ":OUTPUT ACCEPT [0:0]", + ":FORWARD ACCEPT [0:0]", + `-A INPUT -j LOG --log-prefix "audit "`, + "-A INPUT -p tcp -m tcp --dport 9999 -j ACCEPT", + "COMMIT", + "", + }, "\n") + require.NoError(t, os.WriteFile(p4, []byte(v4), 0o644)) + require.NoError(t, os.WriteFile(p6, []byte("*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n"), 0o644)) + + f := &IPTables{IP4Path: p4, IP6Path: p6} + require.NoError(t, f.AddRule(context.Background(), "", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept})) + + got, err := os.ReadFile(p4) + require.NoError(t, err) + require.Contains(t, string(got), "-j LOG", "an additive add must not delete a standalone LOG rule; file:\n%s", got) + require.Contains(t, string(got), "--dport 9999", "existing INPUT rule must survive an additive add") + require.Contains(t, string(got), "--dport 22", "desired rule must be present") +} + +// iptables-save always appends the connlimit counting key (--connlimit-saddr by +// default, or --connlimit-daddr) after a connlimit match. The parser did not +// consume that trailing flag, so it errored ("unsupported option") and +// parseFilterFile silently dropped the whole rule: a per-source connection-limit +// rule vanished from GetRules/Backup and could never be reconciled or removed. +func TestIPTablesConnlimitSaddrRoundTrip(t *testing.T) { + fw := new(IPTables) + + // The exact form iptables-save emits for a per-source connlimit rule. + saved := "-A INPUT -p tcp -m tcp --dport 80 -m connlimit --connlimit-above 20 --connlimit-mask 32 --connlimit-saddr -j REJECT --reject-with icmp-port-unreachable" + got, err := fw.UnmarshalRule(saved, IPv4) + require.NoError(t, err, "connlimit rule with --connlimit-saddr must parse") + want := &Rule{Family: IPv4, Proto: TCP, Port: 80, ConnLimit: &ConnLimit{Count: 20, PerSource: true}, Action: Reject} + require.True(t, want.Equal(got, false), "per-source connlimit must round-trip: got %+v", got.ConnLimit) + + // --connlimit-daddr must also be consumed rather than dropping the rule. + daddr := "-A INPUT -p tcp -m tcp --dport 80 -m connlimit --connlimit-above 5 --connlimit-mask 24 --connlimit-daddr -j DROP" + got, err = fw.UnmarshalRule(daddr, IPv4) + require.NoError(t, err, "connlimit rule with --connlimit-daddr must parse") + require.NotNil(t, got.ConnLimit) + + // A global (mask 0) connlimit still parses and counts globally. + global := "-A INPUT -p tcp -m tcp --dport 80 -m connlimit --connlimit-above 100 --connlimit-mask 0 --connlimit-saddr -j DROP" + got, err = fw.UnmarshalRule(global, IPv4) + require.NoError(t, err) + require.False(t, got.ConnLimit.PerSource, "mask 0 must count globally") +} + +// iptables-save spells an ICMP type carrying a code as `type/code` (e.g. `3/1`). +// The parser rejected the token as an invalid type and dropped the whole rule; +// the Rule model has no code field, so the type is taken and the code ignored. +func TestIPTablesICMPTypeCode(t *testing.T) { + fw := new(IPTables) + got, err := fw.UnmarshalRule("-A INPUT -p icmp -m icmp --icmp-type 3/1 -j DROP", IPv4) + require.NoError(t, err, "icmp type/code rule must parse") + require.NotNil(t, got.ICMPType) + require.Equal(t, uint8(3), *got.ICMPType) + require.Equal(t, Drop, got.Action) +} + +// MoveRule must operate only on the *filter table. A full iptables-save dump also +// carries INPUT/OUTPUT chains in *nat/*mangle; the move must not pull a +// foreign rule out of one of those tables and splice it into *filter (which both +// corrupts the source table and installs a foreign rule as a filter rule). +func TestIPTablesMoveRuleFilterScope(t *testing.T) { + dir := t.TempDir() + p4 := filepath.Join(dir, "iptables") + save := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n" + + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + + "COMMIT\n" + + "*mangle\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + + "-A INPUT -p tcp -m tcp --dport 25 -j DROP\n" + + "COMMIT\n" + require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) + fw := &IPTables{IP4Path: p4} + + // The dport-25 rule lives only in *mangle. Moving it must be a no-op on *filter + // and leave the file untouched. + require.NoError(t, fw.MoveRule(context.Background(), "", &Rule{Proto: TCP, Port: 25, Action: Drop}, 1)) + data, err := os.ReadFile(p4) + require.NoError(t, err) + require.Equal(t, save, string(data), "a rule living only in *mangle must not be moved as a filter rule") +} + +// ipsetParseType decodes the family and set type from an `ipset save` create +// line's fields, defaulting to IPv4 hash:ip. +func TestIPSetParseType(t *testing.T) { + f := new(IPTables) + fam, typ := f.ipsetParseType([]string{"create", "s", "hash:ip"}) + require.Equal(t, IPv4, fam) + require.Equal(t, SetHashIP, typ) + + fam, typ = f.ipsetParseType([]string{"create", "s", "hash:net", "family", "inet6", "hashsize", "1024"}) + require.Equal(t, IPv6, fam, "family inet6 must decode to IPv6") + require.Equal(t, SetHashNet, typ) + + fam, typ = f.ipsetParseType([]string{"create", "s", "hash:ip", "family", "inet"}) + require.Equal(t, IPv4, fam, "family inet stays IPv4") + require.Equal(t, SetHashIP, typ) +} + +// A foreign, standalone `-j LOG` audit line that does not belong to the rule +// being removed must be preserved. The remove path holds a LOG line back as +// "pending" and only folds it into the following action line when their match +// fields agree (iptSameMatch). When they do not agree, removing the action line +// must not also drop the unrelated LOG line. +func TestRemoveRulePreservesUnrelatedLogLine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "iptables.save") + content := strings.Join([]string{ + "*filter", + ":INPUT ACCEPT [0:0]", + ":OUTPUT ACCEPT [0:0]", + `-A INPUT -j LOG --log-prefix "audit "`, + "-A INPUT -p tcp --dport 22 -j ACCEPT", + "COMMIT", + "", + }, "\n") + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + + f := &IPTables{IP4Path: path} + // Remove the unlogged tcp/22 rule; the standalone audit LOG line is unrelated. + target := &Rule{Proto: TCP, Port: 22, Action: Accept} + require.NoError(t, f.RemoveRule(context.Background(), "", target)) + + got, err := os.ReadFile(path) + require.NoError(t, err) + out := string(got) + + require.NotContains(t, out, "--dport 22", "the targeted rule must be removed") + require.Contains(t, out, `LOG --log-prefix "audit "`, + "an unrelated standalone LOG line must not be removed with the rule") +} + +// The MoveRule path has the mirror defect: a standalone `-j LOG` line that does +// not coalesce with the moved rule must not be lifted and dragged to the new +// position. Moving dport 80 to position 1 must reorder it ahead of dport 22 while +// the audit line stays exactly where it was — it begins no logical rule, so it +// neither takes a position nor travels with one. +func TestMoveRuleDoesNotDragUnrelatedLogLine(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "iptables.save") + content := strings.Join([]string{ + "*filter", + ":INPUT ACCEPT [0:0]", + `-A INPUT -j LOG --log-prefix "audit "`, + "-A INPUT -p tcp --dport 22 -j ACCEPT", + "-A INPUT -p tcp --dport 80 -j ACCEPT", + "COMMIT", + "", + }, "\n") + require.NoError(t, os.WriteFile(path, []byte(content), 0644)) + + f := &IPTables{IP4Path: path} + require.NoError(t, f.MoveRule(context.Background(), "", &Rule{Proto: TCP, Port: 80, Action: Accept}, 1)) + + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, []string{ + "*filter", + ":INPUT ACCEPT [0:0]", + `-A INPUT -j LOG --log-prefix "audit "`, + "-A INPUT -p tcp --dport 80 -j ACCEPT", + "-A INPUT -p tcp --dport 22 -j ACCEPT", + "COMMIT", + }, strings.Split(strings.TrimRight(string(data), "\n"), "\n")) +} + +func TestIPTablesLogLimitRoundTrip(t *testing.T) { + f := &IPTables{} + + // A logged rule is two lines that coalesce back to one logical rule. + logged := &Rule{Port: 22, Proto: TCP, Action: Accept, Log: true, LogPrefix: "ssh"} + lines, err := f.marshalRuleLines(logged) + require.NoError(t, err) + require.Len(t, lines, 2, "a logged rule should be a LOG line plus an action line") + var parsed []*Rule + for _, l := range lines { + r, perr := f.UnmarshalRule(l, IPv4) + require.NoError(t, perr, "line %q", l) + parsed = append(parsed, r) + } + coalesced := coalesceLoggedRules(parsed) + require.Len(t, coalesced, 1) + require.True(t, coalesced[0].EqualBase(logged, true), "want %+v got %+v", logged, coalesced[0]) + + // Rate and connection limits round-trip on a single line. + for _, orig := range []*Rule{ + // A non-default burst round-trips; the default of 5 is normalized to 0 (it + // is indistinguishable from iptables' applied default), covered separately + // by TestIPTablesRateBurstDefaultNormalized. + {Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 3}}, + {Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: true}}, + {Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: false}}, + {Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept}, + {Proto: TCP, Port: 80, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, + } { + spec, err := f.MarshalRule(orig) + require.NoError(t, err) + got, err := f.UnmarshalRule(spec, IPv4) + require.NoError(t, err, "spec %q", spec) + require.True(t, got.EqualBase(orig, true), "spec %q: want %+v got %+v", spec, orig, got) + } +} + +// Address sets are staged in the ipset file and reach the kernel only on Reload, +// so every mutation is a file edit and every read comes back off the file. None +// of this shells out to ipset, which is what lets the test run anywhere. +func TestIPTablesAddressSetStaging(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "ipsets") + f := &IPTables{IPSetPath: path} + + set := &AddressSet{Name: "block", Family: IPv6, Type: SetHashNet, Entries: []string{"2001:db8::/64"}} + require.NoError(t, f.AddAddressSet(ctx, set)) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "create block hash:net family inet6\nadd block 2001:db8::/64\n", string(data), + "the staging file holds ipset save format, not a dump of live state") + + // Entries are staged into the file, and adding one already there is a no-op. + require.NoError(t, f.AddAddressSetEntry(ctx, "block", "2001:db8:1::/64")) + require.NoError(t, f.AddAddressSetEntry(ctx, "block", "2001:db8:1::/64")) + got, err := f.GetAddressSet(ctx, "block") + require.NoError(t, err) + require.Equal(t, []string{"2001:db8::/64", "2001:db8:1::/64"}, got.Entries) + require.Equal(t, IPv6, got.Family) + require.Equal(t, SetHashNet, got.Type) + + require.NoError(t, f.RemoveAddressSetEntry(ctx, "block", "2001:db8::/64")) + // A missing entry, and an entry on a set that is not staged, are both no-ops. + require.NoError(t, f.RemoveAddressSetEntry(ctx, "block", "192.0.2.1")) + require.NoError(t, f.RemoveAddressSetEntry(ctx, "ghost", "192.0.2.1")) + got, err = f.GetAddressSet(ctx, "block") + require.NoError(t, err) + require.Equal(t, []string{"2001:db8:1::/64"}, got.Entries) + + // Re-declaring a staged set merges entries; re-declaring it with a different + // family or type is a conflict rather than a silent rewrite. + require.NoError(t, f.AddAddressSet(ctx, &AddressSet{ + Name: "block", Family: IPv6, Type: SetHashNet, Entries: []string{"2001:db8:2::/64"}})) + got, err = f.GetAddressSet(ctx, "block") + require.NoError(t, err) + require.Equal(t, []string{"2001:db8:1::/64", "2001:db8:2::/64"}, got.Entries) + require.ErrorContains(t, + f.AddAddressSet(ctx, &AddressSet{Name: "block", Family: IPv4, Type: SetHashNet}), + "already staged") + + // An entry on a set that was never staged reports what ipset itself would. + require.ErrorContains(t, f.AddAddressSetEntry(ctx, "ghost", "192.0.2.1"), "does not exist") + + // Removal drops the set from the file and queues the kernel-side destroy for + // Reload; removing an unstaged set is a no-op that queues nothing. + require.NoError(t, f.RemoveAddressSet(ctx, "block")) + require.Equal(t, []string{"block"}, f.pendingSetRemovals) + require.NoError(t, f.RemoveAddressSet(ctx, "ghost")) + require.Equal(t, []string{"block"}, f.pendingSetRemovals) + sets, err := f.GetAddressSets(ctx) + require.NoError(t, err) + require.Empty(t, sets) + + // Staging the set again before that Reload cancels the queued destroy, which + // would otherwise delete the set the caller just asked for. + require.NoError(t, f.AddAddressSet(ctx, set)) + require.Empty(t, f.pendingSetRemovals) +} + +// A FamilyAny set is staged as IPv4: a set is family-typed, so the file always +// names a concrete family for `ipset restore` to create it with. +func TestIPTablesAddressSetFamilyAnyStagedAsIPv4(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "ipsets") + f := &IPTables{IPSetPath: path} + + require.NoError(t, f.AddAddressSet(ctx, &AddressSet{Name: "any", Type: SetHashIP})) + got, err := f.GetAddressSet(ctx, "any") + require.NoError(t, err) + require.Equal(t, IPv4, got.Family) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(data), "family inet") +} + +// The save-format decode is single-pass and must survive the oddities a real +// staging file carries, since a misread here silently changes what Reload loads. +func TestIPTablesIPSetSaveDecode(t *testing.T) { + f := &IPTables{} + lines := []string{ + "create v4 hash:ip family inet hashsize 1024 maxelem 65536", + "add v4 192.0.2.1 timeout 600", + `add v4 192.0.2.2 comment "note"`, + "create v6 hash:net family inet6", + "add v6 2001:db8::/64", + // A second create for a name already decoded, and an add naming a set this + // file never declares, are both dropped rather than corrupting the decode. + "create v4 hash:net family inet6", + "add ghost 198.51.100.1", + "", + "# a comment", + } + want := []*AddressSet{ + {Name: "v4", Family: IPv4, Type: SetHashIP, Entries: []string{"192.0.2.1", "192.0.2.2"}}, + {Name: "v6", Family: IPv6, Type: SetHashNet, Entries: []string{"2001:db8::/64"}}, + } + path := filepath.Join(t.TempDir(), "ipsets") + require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0600)) + scanned, err := f.scanIPSetSave(path) + require.NoError(t, err) + require.Equal(t, want, scanned) + + // What the backend renders round-trips through the decode unchanged. + rendered := filepath.Join(t.TempDir(), "rendered") + require.NoError(t, os.WriteFile(rendered, f.marshalIPSetSave(want), 0600)) + round, err := f.scanIPSetSave(rendered) + require.NoError(t, err) + require.Equal(t, want, round) + + // A staging file that was never written decodes as no sets, not an error. + missing, err := f.scanIPSetSave(filepath.Join(t.TempDir(), "absent")) + require.NoError(t, err) + require.Empty(t, missing) +} + +// Encoding entries for the kernel is work the `ipset` binary used to do for us, +// so the address/CIDR forms a set can hold are pinned here. Decoding must land +// back on the exact string the save-format decode produces for the same entry, +// or a set read live and the same set read from a staging file would not compare +// equal. +func TestIPSetEntryEncoding(t *testing.T) { + for _, tc := range []struct { + entry string + ip string + cidr uint8 + // decoded is the entry as it reads back, which differs from entry only + // where host bits were supplied and the kernel masks them off. + decoded string + }{ + {entry: "192.0.2.10", ip: "192.0.2.10", cidr: 0, decoded: "192.0.2.10"}, + {entry: "10.0.0.0/8", ip: "10.0.0.0", cidr: 8, decoded: "10.0.0.0/8"}, + {entry: "192.0.2.5/24", ip: "192.0.2.0", cidr: 24, decoded: "192.0.2.0/24"}, + {entry: "2001:db8::1", ip: "2001:db8::1", cidr: 0, decoded: "2001:db8::1"}, + {entry: "2001:db8::/64", ip: "2001:db8::", cidr: 64, decoded: "2001:db8::/64"}, + } { + got, err := ipsetEncodeEntry(tc.entry) + require.NoError(t, err, "entry %q", tc.entry) + require.Equal(t, tc.ip, got.IP.String(), "entry %q", tc.entry) + require.Equal(t, tc.cidr, got.CIDR, "entry %q", tc.entry) + require.True(t, got.Replace, "entry %q must carry the kernel's exist flag", tc.entry) + require.Equal(t, tc.decoded, ipsetDecodeEntry(*got), "entry %q", tc.entry) + } + + // A prefix covering the whole address is dropped, which is how `ipset save` + // writes a single host held in a hash:net set. + require.Equal(t, "192.0.2.1", ipsetDecodeEntry(netlink.IPSetEntry{IP: net.ParseIP("192.0.2.1"), CIDR: 32})) + require.Equal(t, "2001:db8::1", ipsetDecodeEntry(netlink.IPSetEntry{IP: net.ParseIP("2001:db8::1"), CIDR: 128})) + // An entry the kernel sent without an address has no representation. + require.Empty(t, ipsetDecodeEntry(netlink.IPSetEntry{})) + + for _, bad := range []string{"", "not-an-ip", "192.0.2.0/33", "example.com", "192.0.2.1-192.0.2.9"} { + _, err := ipsetEncodeEntry(bad) + require.Error(t, err, "entry %q must be rejected rather than sent to the kernel", bad) + } +} + +// setRefFamily consults the staged sets first and falls back to the live kernel +// for a set that exists only there, and a rule that already carries a family +// bypasses resolution entirely. +func TestIPTablesSetRefFamilyFallbackChain(t *testing.T) { + dir := t.TempDir() + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(scaffold), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + ipsets := filepath.Join(dir, "ipsets") + require.NoError(t, os.WriteFile(ipsets, []byte( + "create bootset hash:ip family inet6\nadd bootset 2001:db8::1\n"), 0644)) + f := &IPTables{IP4Path: p4, IP6Path: p6, IPSetPath: ipsets} + ctx := context.Background() + + // The live query knows no sets; "errored" additionally exercises the + // netlink-failure path, which falls through to the declared sets. + prev := ipsetLiveFamily + t.Cleanup(func() { ipsetLiveFamily = prev }) + ipsetLiveFamily = func(name string) (Family, bool, error) { + if name == "errored" { + return FamilyAny, false, fmt.Errorf("netlink unavailable") + } + return FamilyAny, false, nil + } + + // Declared only in the persistence file: pinned to its inet6 family. + require.NoError(t, f.AddRule(ctx, "", &Rule{Proto: TCP, Port: 4220, Source: "bootset", Action: Accept})) + d4, err := os.ReadFile(p4) + require.NoError(t, err) + d6, err := os.ReadFile(p6) + require.NoError(t, err) + require.Contains(t, string(d6), "--match-set bootset src") + require.NotContains(t, string(d4), "bootset", + "a set declared inet6 in boot config must not produce an iptables line") + + // A netlink failure still falls through to the declared file; the set is + // not there either, so the add fails as unknown rather than writing a + // blind line. + err = f.AddRule(ctx, "", &Rule{Proto: TCP, Port: 4221, Source: "errored", Action: Accept}) + require.ErrorContains(t, err, `"errored"`) + + // A rule that already carries a family is written as declared: no source + // knows "errored", so success proves resolution was never consulted. + require.NoError(t, f.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 4222, Source: "errored", Action: Accept})) + d4, err = os.ReadFile(p4) + require.NoError(t, err) + require.Contains(t, string(d4), "--match-set errored src") +} + +func TestIPTablesNATRoundTrip(t *testing.T) { + f := &IPTables{} + cases := []*NATRule{ + {Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Interface: "eth0"}, + {Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}, + {Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4", Interface: "eth1"}, + // SNAT with a source-port translation is now accepted: iptables emits it + // as --to-source addr:port (pf/nft reject it in their own MarshalNATRule). + {Kind: SNAT, Family: IPv4, Proto: TCP, Source: "10.0.0.0/24", ToAddress: "1.2.3.4", ToPort: 8443, Interface: "eth1"}, + {Kind: Masquerade, Family: IPv4, Interface: "eth1"}, + } + for _, orig := range cases { + spec, err := f.MarshalNATRule(orig) + require.NoError(t, err) + got, err := f.UnmarshalNATRule(spec, IPv4) + require.NoError(t, err, "spec %q", spec) + require.True(t, got.EqualBase(orig), "spec %q: want %+v got %+v", spec, orig, got) + } +} + +func TestIPTablesProtocolAndComment(t *testing.T) { + f := &IPTables{} + cases := []*Rule{ + {Proto: SCTP, Port: 9000, Action: Accept}, + {Proto: GRE, Action: Accept}, + {Proto: ESP, Action: Accept}, + {Proto: AH, Action: Drop}, + {Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"}, + } + for _, orig := range cases { + spec, err := f.MarshalRule(orig) + require.NoError(t, err) + got, err := f.UnmarshalRule(spec, IPv4) + require.NoError(t, err, "spec %q", spec) + require.True(t, got.EqualBase(orig, true), "spec %q: want %+v got %+v", spec, orig, got) + require.Equal(t, orig.Comment, got.Comment, "spec %q comment", spec) + } +} + +// TestIPTablesNATParserRejectsUnmodeledMatches pins the shapes UnmarshalNATRule +// must leave foreign: a negated interface/protocol/port match (reading +// `! -o docker0` as a plain match would invert Docker's stock masquerade rule), +// an unknown protocol token, and a source-or-destination multiport option. +func TestIPTablesNATParserRejectsUnmodeledMatches(t *testing.T) { + fw := new(IPTables) + foreign := []string{ + "-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE", + "-A PREROUTING ! -i eth0 -p tcp -m tcp --dport 80 -j REDIRECT --to-ports 8080", + "-A PREROUTING ! -p tcp -j REDIRECT --to-ports 8080", + "-A PREROUTING -p tcp -m tcp ! --dport 80 -j REDIRECT --to-ports 8080", + "-A PREROUTING -p udplite -j REDIRECT --to-ports 8080", + "-A PREROUTING -p 47 -j REDIRECT --to-ports 8080", + "-A PREROUTING -p udp -m multiport --ports 5060 -j REDIRECT --to-ports 5061", + } + for _, spec := range foreign { + _, err := fw.UnmarshalNATRule(spec, IPv4) + require.Error(t, err, "line must stay foreign: %s", spec) + } + + // A negated address still models, and the plain forms still parse. + r, err := fw.UnmarshalNATRule("-A POSTROUTING ! -s 172.17.0.0/16 -o eth0 -j MASQUERADE", IPv4) + require.NoError(t, err) + require.Equal(t, "!172.17.0.0/16", r.Source) + require.Equal(t, "eth0", r.Interface) + r, err = fw.UnmarshalNATRule("-A PREROUTING -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.0.0.5:80", IPv4) + require.NoError(t, err) + require.Equal(t, DNAT, r.Kind) + require.EqualValues(t, 8080, r.Port) +} + +// TestIPTablesRewriteNATPreservesUnmodeled pins that a Restore-style nat rewrite +// keeps managed-chain lines the model cannot hold — Docker's addrtype jump — while +// still replacing the modeled rules. +func TestIPTablesRewriteNATPreservesUnmodeled(t *testing.T) { + dir := t.TempDir() + save := "*nat\n:PREROUTING ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n" + + "-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER\n" + + "-A PREROUTING -p tcp -m tcp --dport 8080 -j DNAT --to-destination 10.0.0.5:80\n" + + "-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE\n" + + "COMMIT\n*filter\n:INPUT ACCEPT [0:0]\nCOMMIT\n" + p := filepath.Join(dir, "iptables") + require.NoError(t, os.WriteFile(p, []byte(save), 0644)) + fw := &IPTables{IP4Path: p, IP6Path: filepath.Join(dir, "ip6tables")} + + require.NoError(t, fw.rewriteNATRules(p, []string{"-A PREROUTING -p tcp -m tcp --dport 9090 -j DNAT --to-destination 10.0.0.6:90"})) + got, err := os.ReadFile(p) + require.NoError(t, err) + body := string(got) + require.Contains(t, body, "-j DOCKER", "Docker's unmodeled prerouting jump must survive the rewrite") + require.Contains(t, body, "! -o docker0", "Docker's negated masquerade must survive the rewrite") + require.Contains(t, body, "--dport 9090", "the desired rule must be written") + require.NotContains(t, body, "--dport 8080", "the replaced modeled rule must be gone") +} + +// TestIPTablesLogPairSplitByForeignLine pins that a LOG line and an action line +// separated by an unmodeled foreign line are not coalesced into one logged rule: +// the pair reads apart everywhere (GetRules, positions, removal), so reporting a +// merged rule would surface one no removal could ever find. +func TestIPTablesLogPairSplitByForeignLine(t *testing.T) { + dir := t.TempDir() + save := "*filter\n:INPUT ACCEPT [0:0]\n" + + "-A INPUT -p tcp -m tcp --dport 80 -j LOG --log-prefix \"web: \"\n" + + "-A INPUT -m recent --name probe --set -j DROP\n" + + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + + "COMMIT\n" + p := filepath.Join(dir, "iptables") + require.NoError(t, os.WriteFile(p, []byte(save), 0644)) + fw := &IPTables{IP4Path: p, IP6Path: filepath.Join(dir, "ip6tables")} + + rules, err := fw.parseFilterFile(p, IPv4) + require.NoError(t, err) + require.Len(t, rules, 1, "only the plain accept is a modeled rule") + require.False(t, rules[0].Log, "the accept must not inherit the separated LOG line") + + // Removing the reported rule must find its line even with the split pair. + ctx := context.Background() + require.NoError(t, os.WriteFile(filepath.Join(dir, "ip6tables"), []byte("*filter\nCOMMIT\n"), 0644)) + require.NoError(t, fw.RemoveRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept})) + got, err := os.ReadFile(p) + require.NoError(t, err) + require.NotContains(t, string(got), "-j ACCEPT", "the reported rule must be removable") + require.Contains(t, string(got), "-j LOG", "the orphaned LOG line is unmodeled and preserved") + require.Contains(t, string(got), "-m recent", "the foreign line is preserved") +} + +// TestIPTablesInsertPositionSkipsForeignLine pins that Insert positions count +// only the rules GetRules reports: an unmodeled foreign line holds no position, +// so Rule.Number and the InsertRule position argument cannot diverge. +func TestIPTablesInsertPositionSkipsForeignLine(t *testing.T) { + dir := t.TempDir() + save := "*filter\n:INPUT ACCEPT [0:0]\n" + + "-A INPUT -m recent --name probe --set -j DROP\n" + + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + + "-A INPUT -p tcp -m tcp --dport 80 -j ACCEPT\n" + + "COMMIT\n" + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(save), 0644)) + require.NoError(t, os.WriteFile(p6, []byte("*filter\nCOMMIT\n"), 0644)) + fw := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // GetRules reports #1 dport 22, #2 dport 80. Insert at 2 = between them. + require.NoError(t, fw.InsertRule(ctx, "", 2, &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept})) + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + byPort := map[uint16]int{} + for _, r := range rules { + byPort[r.Port] = r.Number + } + require.Equal(t, 1, byPort[22]) + require.Equal(t, 2, byPort[443], "position 2 must land between the two modeled rules, not before dport 22") + require.Equal(t, 3, byPort[80]) + got, err := os.ReadFile(p4) + require.NoError(t, err) + require.Contains(t, string(got), "-m recent", "the foreign line survives the insert") +} + +// dockerSaveFile is a realistic iptables-save dump of a host running Docker: the +// classic DOCKER-* chain layout plus an operator's own INPUT and FORWARD rules +// and a hand-named br-lan bridge, which must stay managed. Only three of Docker's +// lines parse as modeled rules — the two FORWARD accepts and the per-published- +// port hairpin masquerade — and those are the ones the library must not touch. +const dockerSaveFile = "*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n:DOCKER - [0:0]\n" + + "-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER\n" + + "-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE\n" + + "-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE\n" + + "-A POSTROUTING -o eth0 -j MASQUERADE\n" + + "-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80\n" + + "COMMIT\n" + + "*filter\n:INPUT DROP [0:0]\n:FORWARD DROP [0:0]\n:OUTPUT ACCEPT [0:0]\n" + + ":DOCKER - [0:0]\n:DOCKER-USER - [0:0]\n:DOCKER-ISOLATION-STAGE-1 - [0:0]\n" + + "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT\n" + + "-A FORWARD -j DOCKER-USER\n" + + "-A FORWARD -j DOCKER-ISOLATION-STAGE-1\n" + + "-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n" + + "-A FORWARD -o docker0 -j DOCKER\n" + + "-A FORWARD -i docker0 ! -o docker0 -j ACCEPT\n" + + "-A FORWARD -i docker0 -o docker0 -j ACCEPT\n" + + "-A FORWARD -i br-1a2b3c4d5e6f -o br-1a2b3c4d5e6f -j ACCEPT\n" + + "-A FORWARD -i br-lan -o eth0 -j ACCEPT\n" + + "-A DOCKER-USER -j RETURN\n" + + "COMMIT\n" + +// dockerTestFirewall stages the Docker save file and returns the backend plus the +// IPv4 file path. +func dockerTestFirewall(t *testing.T) (*IPTables, string) { + t.Helper() + dir := t.TempDir() + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + require.NoError(t, os.WriteFile(p4, []byte(dockerSaveFile), 0644)) + require.NoError(t, os.WriteFile(p6, []byte("*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n"), 0644)) + // A staging path keeps Backup's address-set read on the file rather than the + // live kernel, which an unprivileged test cannot query. + return &IPTables{IP4Path: p4, IP6Path: p6, IPSetPath: filepath.Join(dir, "ipsets")}, p4 +} + +// Docker owns and continuously reconciles its rules, so GetRules must not report +// them: a reported rule enters no desired set, and Sync would delete it and sever +// container networking. The operator's own rules — including one on a hand-named +// br-lan bridge — must still be reported and keep correct Numbers, since a +// container rule consumes no logical position, exactly like an unmodeled line. +func TestIPTablesGetRulesHidesContainerRuntime(t *testing.T) { + f, _ := dockerTestFirewall(t) + + rules, err := f.GetRules(context.Background(), "") + require.NoError(t, err) + + for _, r := range rules { + require.False(t, isContainerRuntimeIface(r.InInterface), + "a rule on container interface %q must not be reported", r.InInterface) + require.False(t, isContainerRuntimeIface(r.OutInterface), + "a rule on container interface %q must not be reported", r.OutInterface) + } + + require.Len(t, rules, 2, "only the operator's two rules are in scope") + + require.Equal(t, DirInput, rules[0].Direction) + require.EqualValues(t, 22, rules[0].Port) + require.Equal(t, 1, rules[0].Number) + + // The operator's FORWARD rule is the only reported forward rule, so it is + // number 1 in that chain — Docker's rules consume no logical position. + require.Equal(t, DirForward, rules[1].Direction) + require.Equal(t, "br-lan", rules[1].InInterface, "a hand-named bridge stays managed") + require.Equal(t, "eth0", rules[1].OutInterface) + require.Equal(t, 1, rules[1].Number) +} + +// The nat counterpart: Docker's hairpin masquerade is the only nat line that +// parses, and it must be hidden, while the operator's egress masquerade is +// reported and numbered as the first POSTROUTING rule. +func TestIPTablesGetNATRulesHidesContainerRuntime(t *testing.T) { + f, _ := dockerTestFirewall(t) + + nats, err := f.GetNATRules(context.Background(), "") + require.NoError(t, err) + require.Len(t, nats, 1, "only the operator's masquerade is in scope") + require.Equal(t, "eth0", nats[0].Interface) + require.Equal(t, 1, nats[0].Number) +} + +// A Backup/Restore round trip is the sharpest test: Backup captures only what +// GetRules reports, and Restore rewrites the managed chains from that set. Every +// Docker line must survive verbatim, or restoring a backup would tear down +// container networking on a host that was running fine. +func TestIPTablesRestorePreservesContainerRuntime(t *testing.T) { + f, p4 := dockerTestFirewall(t) + ctx := context.Background() + + backup, err := f.Backup(ctx, "") + require.NoError(t, err) + for _, r := range backup.Rules { + require.False(t, r.isContainerRuntime(), "a container rule must never reach a backup") + } + require.NoError(t, f.Restore(ctx, "", backup)) + + out, err := os.ReadFile(p4) + require.NoError(t, err) + got := string(out) + + for _, line := range []string{ + "-A FORWARD -j DOCKER-USER", + "-A FORWARD -j DOCKER-ISOLATION-STAGE-1", + "-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT", + "-A FORWARD -o docker0 -j DOCKER", + "-A FORWARD -i docker0 ! -o docker0 -j ACCEPT", + "-A FORWARD -i docker0 -o docker0 -j ACCEPT", + "-A FORWARD -i br-1a2b3c4d5e6f -o br-1a2b3c4d5e6f -j ACCEPT", + "-A POSTROUTING -s 172.17.0.2/32 -d 172.17.0.2/32 -p tcp -m tcp --dport 80 -j MASQUERADE", + "-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE", + "-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER", + "-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80", + } { + require.Contains(t, got, line, "Docker's rule must survive a Backup/Restore round trip") + } + + // The operator's own rules are still restored, and the round trip is stable. + require.Contains(t, got, "--dport 22") + require.Contains(t, got, "-A FORWARD -i br-lan -o eth0 -j ACCEPT") + + backup2, err := f.Backup(ctx, "") + require.NoError(t, err) + require.NoError(t, f.Restore(ctx, "", backup2)) + out2, err := os.ReadFile(p4) + require.NoError(t, err) + require.Equal(t, got, string(out2), "Restore must be idempotent with container rules present") +} + +// Sync is the path that would actually do the damage: it removes every reported +// rule the desired set does not cover. With Docker's rules hidden, a caller +// syncing only its own INPUT rule must leave every Docker line intact. +func TestIPTablesSyncLeavesContainerRuntimeIntact(t *testing.T) { + f, p4 := dockerTestFirewall(t) + ctx := context.Background() + + desired := []*Rule{{Direction: DirInput, Proto: TCP, Port: 443, Action: Accept}} + _, removed, err := Sync(ctx, f, "", desired) + require.NoError(t, err) + require.Equal(t, 2, removed, "only the operator's two rules are reconciled") + + out, err := os.ReadFile(p4) + require.NoError(t, err) + got := string(out) + require.Contains(t, got, "-A FORWARD -i docker0 -o docker0 -j ACCEPT", "Sync must not delete Docker's rule") + require.Contains(t, got, "-A FORWARD -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT") + require.Contains(t, got, "-A FORWARD -i br-1a2b3c4d5e6f -o br-1a2b3c4d5e6f -j ACCEPT") + require.Contains(t, got, "--dport 443", "the desired rule is applied") + require.NotContains(t, got, "--dport 22", "the operator's stale rule is reconciled away") +} + +// Even a caller that hand-builds a rule matching a Docker line exactly must not +// be able to remove it through RemoveRule. +func TestIPTablesRemoveRuleRefusesContainerRuntime(t *testing.T) { + f, p4 := dockerTestFirewall(t) + + target := &Rule{Direction: DirForward, InInterface: "docker0", OutInterface: "docker0", Action: Accept} + require.NoError(t, f.RemoveRule(context.Background(), "", target)) + + out, err := os.ReadFile(p4) + require.NoError(t, err) + require.Contains(t, string(out), "-A FORWARD -i docker0 -o docker0 -j ACCEPT", + "Docker's rule must survive an exact-match removal attempt") +} diff --git a/livecounters_linux.go b/livecounters_linux.go new file mode 100644 index 0000000..9b841d7 --- /dev/null +++ b/livecounters_linux.go @@ -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 +} diff --git a/manager_darwin.go b/manager_darwin.go new file mode 100644 index 0000000..80a7d4f --- /dev/null +++ b/manager_darwin.go @@ -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") +} diff --git a/manager_freebsd.go b/manager_freebsd.go new file mode 100644 index 0000000..2e1f584 --- /dev/null +++ b/manager_freebsd.go @@ -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") +} diff --git a/manager_linux.go b/manager_linux.go new file mode 100644 index 0000000..e12f68a --- /dev/null +++ b/manager_linux.go @@ -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 +} diff --git a/manager_linux_test.go b/manager_linux_test.go new file mode 100644 index 0000000..08b0f21 --- /dev/null +++ b/manager_linux_test.go @@ -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) + } +} diff --git a/manager_windows.go b/manager_windows.go new file mode 100644 index 0000000..a542037 --- /dev/null +++ b/manager_windows.go @@ -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) +} diff --git a/nft_linux.go b/nft_linux.go new file mode 100644 index 0000000..c5ccc88 --- /dev/null +++ b/nft_linux.go @@ -0,0 +1,3264 @@ +package firewall + +import ( + "bytes" + "context" + "errors" + "fmt" + "hash/fnv" + "net" + "net/netip" + "sort" + "strings" + "sync" + "sync/atomic" + + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "github.com/google/nftables/userdata" + "go4.org/netipx" + "golang.org/x/sys/unix" +) + +const ( + // NFTDefaultTable is the table name used when no rule prefix is supplied. + NFTDefaultTable = "go_firewall" + // nftMeterSetSize is the dynamic-set size nft itself gives a meter, kept so a + // set this backend creates is indistinguishable from one nft would have made. + nftMeterSetSize = 65535 + // nftCommentMax is the longest rule comment nftables stores (its own limit). + nftCommentMax = 128 + // nftLogPrefixMax is the longest log prefix the kernel's nf_log accepts. + nftLogPrefixMax = 127 +) + +// NFT manages firewall rules through the nftables netlink API. To avoid +// clobbering rules owned by other tooling, every rule this backend creates lives +// in a private `inet` table (named after the rule prefix) with its own input and +// output base chains. Writes are scoped to that table; reads also report rules +// found in other tables so callers can see the whole ruleset. +type NFT struct { + // table is the nftables table this backend owns. + table string + // mu guards the ensured/natEnsured flags so concurrent callers do not race on + // the one-time table/chain setup. + mu sync.Mutex + // ensured records whether the private table/chains have been created this + // session, so the setup runs only once. + ensured bool + // natEnsured records the same for the nat base chains, which are created + // lazily only when a NAT rule is first written. + natEnsured bool +} + +// nftSetID hands out the transaction-local identifiers an anonymous set is +// referenced by within a batch. Only uniqueness inside one batch matters, so a +// process-wide counter is sufficient; it also keeps the library's own +// auto-allocation (which only runs for a set with ID 0) out of the picture. +var nftSetID atomic.Uint32 + +// nextSetID returns the next anonymous-set identifier. +func (f *NFT) nextSetID() uint32 { + return nftSetID.Add(1) +} + +// sanitizeNFTName reduces an arbitrary prefix to a valid nftables identifier +// (letters, digits and underscores), falling back to the default when nothing +// usable remains. +func sanitizeNFTName(prefix string) string { + var b strings.Builder + for _, r := range prefix { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_': + b.WriteRune(r) + case r == '-' || r == ' ' || r == '.': + b.WriteRune('_') + } + } + name := strings.Trim(b.String(), "_") + if name == "" { + return NFTDefaultTable + } + // An nftables identifier must begin with a letter; a digit-led prefix would + // make every later table/chain creation fail. + if c := name[0]; c >= '0' && c <= '9' { + name = "fw_" + name + } + return name +} + +// nftConn returns a transaction scope for one operation. Despite the name this +// opens nothing: nftables.New only allocates, and the netlink socket is dialed +// and closed per operation inside the library either way, so there is no +// connection here worth caching on the backend. +// +// What the value does carry is a batch: staged additions and deletions +// accumulate on it until Flush sends and clears them all. That is why each +// operation takes its own rather than sharing one — a Flush on a shared scope +// would commit whatever another caller had half-staged. +func nftConn() (*nftables.Conn, error) { + return nftables.New() +} + +// NewNFT constructs an nftables-backed Manager, deriving the private table name +// from rulePrefix and verifying nf_tables is reachable over netlink. +func NewNFT(ctx context.Context, rulePrefix string) (*NFT, error) { + nft := &NFT{table: sanitizeNFTName(rulePrefix)} + + // Confirm the nf_tables subsystem answers. Opening the socket alone proves + // little, so list the ruleset's tables: that fails without the kernel module + // or the privileges every later operation needs. + c, err := nftConn() + if err != nil { + return nil, fmt.Errorf("unable to open the nftables netlink socket: %s", err) + } + if _, err := c.ListTables(); err != nil { + return nil, fmt.Errorf("unable to list the nftables ruleset: %s", err) + } + + return nft, nil +} + +// Type returns the manager type. +func (f *NFT) Type() string { + return NFTType +} + +// Capabilities returns the set of features this backend can express. +func (f *NFT) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + IPv6: true, + PortPair: true, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: true, + DefaultPolicy: true, + RuleCounters: true, + AddressSets: true, + Comments: true, + Negation: true, + RejectAction: true, + FamilyWithoutAddress: true, + } +} + +// GetZone reports no zone; nftables has no interface-to-zone mapping in this model. +func (f *NFT) GetZone(ctx context.Context, iface string) (zoneName string, err error) { + return "", nil +} + +// tableRef returns the netlink handle for this backend's private inet table. +func (f *NFT) tableRef() *nftables.Table { + return &nftables.Table{Family: nftables.TableFamilyINet, Name: f.table} +} + +// chainRef returns the netlink handle for one of the private table's chains. +// Only the name and table identify a chain for rule operations, so the hook +// properties are left unset here; ensureTable owns their definition. +func (f *NFT) chainRef(name string) *nftables.Chain { + return &nftables.Chain{Name: name, Table: f.tableRef()} +} + +// nftFilterChains lists the private table's filter base chains, in the order a +// read enumerates them. +var nftFilterChains = []string{"input", "output", "forward"} + +// nftNATChains lists the private table's nat base chains, in read order. +var nftNATChains = []string{"prerouting", "postrouting"} + +// directionForChain returns the rule direction a filter base-chain name maps +// to (the inverse of chainForDirection). +func (f *NFT) directionForChain(chain string) Direction { + switch chain { + case "output": + return DirOutput + case "forward": + return DirForward + } + return DirInput +} + +// chainForDirection returns the filter base-chain name a rule of the given +// direction lives in. +func (f *NFT) chainForDirection(d Direction) string { + switch d { + case DirOutput: + return "output" + case DirForward: + return "forward" + } + return "input" +} + +// familyName renders a netlink table family as the keyword nft prints, so a +// foreign rule's recorded table reads the way an operator would write it +// ("inet filter"). The container-runtime table check parses this same form. +func (f *NFT) familyName(fam nftables.TableFamily) string { + switch fam { + case nftables.TableFamilyIPv4: + return "ip" + case nftables.TableFamilyIPv6: + return "ip6" + case nftables.TableFamilyINet: + return "inet" + case nftables.TableFamilyARP: + return "arp" + case nftables.TableFamilyBridge: + return "bridge" + case nftables.TableFamilyNetdev: + return "netdev" + } + return "unknown" +} + +// familyForTable returns the family every row of a single-family table matches. +// An ip or ip6 table is the family qualifier for its own rules, so those rows +// carry no nfproto match of their own; an inet table (this backend's own, and +// the arp/bridge/netdev families) settles nothing, reporting false. +func (f *NFT) familyForTable(tbl *nftables.Table) (Family, bool) { + if tbl == nil { + return FamilyAny, false + } + switch tbl.Family { + case nftables.TableFamilyIPv4: + return IPv4, true + case nftables.TableFamilyIPv6: + return IPv6, true + } + return FamilyAny, false +} + +// isNotExist reports whether a netlink error means the object is simply absent, +// which every read path treats as "nothing there yet" rather than a failure. +func (f *NFT) isNotExist(err error) bool { + return errors.Is(err, unix.ENOENT) +} + +// ----------------------------------------------------------------------------- +// Expression encoding +// ----------------------------------------------------------------------------- + +// nftAnonSet is an anonymous constant set an encoded rule references. It must be +// created in the same netlink batch as the rule, before the rule's own message. +type nftAnonSet struct { + set *nftables.Set + elements []nftables.SetElement +} + +// nftEncoded is a marshalled rule: the chain it belongs in, the expression list +// the kernel stores, the comment as nftables user data, any anonymous sets the +// expressions reference, and the named dynamic set a per-source connection limit +// counts in. Encoding is pure — nothing here touches the kernel — so a caller +// can inspect the result before committing it. +type nftEncoded struct { + chain string + exprs []expr.Any + userData []byte + anonSets []nftAnonSet + // meterSet is the named dynamic set a per-source connection-limit rule + // counts in; it outlives the batch and is created separately. + meterSet *nftables.Set +} + +// ifnameBytes renders an interface name as the comparison operand nftables +// expects: a trailing '*' is a prefix match, compared against just the leading +// characters, and anything else is an exact match against the fixed 16-byte +// (IFNAMSIZ) NUL-padded buffer. +func (f *NFT) ifnameBytes(name string) []byte { + if strings.HasSuffix(name, "*") { + return []byte(strings.TrimSuffix(name, "*")) + } + b := make([]byte, unix.IFNAMSIZ) + copy(b, name) + return b +} + +// ifnameString reverses ifnameBytes: a full-width operand is an exact name with +// its NUL padding trimmed, a short one a prefix match rendered back with '*'. +func (f *NFT) ifnameString(data []byte) string { + if len(data) == unix.IFNAMSIZ { + return string(bytes.TrimRight(data, "\x00")) + } + return string(bytes.TrimRight(data, "\x00")) + "*" +} + +// nfprotoByte returns the NFPROTO constant a family pins to. +func (f *NFT) nfprotoByte(fam Family) byte { + if fam == IPv6 { + return unix.NFPROTO_IPV6 + } + return unix.NFPROTO_IPV4 +} + +// familyForNFProto reverses nfprotoByte. +func (f *NFT) familyForNFProto(b byte) (Family, bool) { + switch b { + case unix.NFPROTO_IPV4: + return IPv4, true + case unix.NFPROTO_IPV6: + return IPv6, true + } + return FamilyAny, false +} + +// addrField describes where a source or destination address sits in the network +// header of the given family, which is what a payload load must name. +func addrField(fam Family, source bool) (offset, length uint32) { + if fam == IPv6 { + if source { + return 8, 16 + } + return 24, 16 + } + if source { + return 12, 4 + } + return 16, 4 +} + +// ipProtoByte returns the IP protocol number nftables matches a protocol by. +func (f *NFT) ipProtoByte(p Protocol) (byte, bool) { + switch p { + case TCP: + return unix.IPPROTO_TCP, true + case UDP: + return unix.IPPROTO_UDP, true + case ICMP: + return unix.IPPROTO_ICMP, true + case ICMPv6: + return unix.IPPROTO_ICMPV6, true + case SCTP: + return unix.IPPROTO_SCTP, true + case GRE: + return unix.IPPROTO_GRE, true + case ESP: + return unix.IPPROTO_ESP, true + case AH: + return unix.IPPROTO_AH, true + } + return 0, false +} + +// protocolForByte reverses ipProtoByte, returning ProtocolAny for a protocol the +// Rule model has no field for. +func (f *NFT) protocolForByte(b byte) Protocol { + switch b { + case unix.IPPROTO_TCP: + return TCP + case unix.IPPROTO_UDP: + return UDP + case unix.IPPROTO_ICMP: + return ICMP + case unix.IPPROTO_ICMPV6: + return ICMPv6 + case unix.IPPROTO_SCTP: + return SCTP + case unix.IPPROTO_GRE: + return GRE + case unix.IPPROTO_ESP: + return ESP + case unix.IPPROTO_AH: + return AH + } + return ProtocolAny +} + +// nftConnStates maps the library's connection-state bits onto the kernel's ct +// state bits, which do not share an ordering: the library counts from new, +// netfilter from invalid. +var nftConnStates = []struct { + state ConnState + bit uint32 +}{ + {StateNew, 0x08}, + {StateEstablished, 0x02}, + {StateRelated, 0x04}, + {StateInvalid, 0x01}, +} + +// ctStateMask renders a connection-state set as the kernel bitmask a ct state +// match tests against. +func (f *NFT) ctStateMask(s ConnState) uint32 { + var mask uint32 + for _, cs := range nftConnStates { + if s&cs.state != 0 { + mask |= cs.bit + } + } + return mask +} + +// connStateForMask reverses ctStateMask, reporting false when the mask carries a +// state the Rule model cannot hold (untracked, say), so the row stays opaque +// rather than being silently narrowed to the states that did map. +func (f *NFT) connStateForMask(mask uint32) (ConnState, bool) { + var state ConnState + var covered uint32 + for _, cs := range nftConnStates { + if mask&cs.bit != 0 { + state |= cs.state + covered |= cs.bit + } + } + if covered != mask { + return 0, false + } + return state, true +} + +// rateUnitSeconds returns the number of seconds a rate unit spans, which is how +// nftables expresses a limit's interval. +func rateUnitSeconds(u RateUnit) uint64 { + switch u { + case PerMinute: + return 60 + case PerHour: + return 3600 + case PerDay: + return 86400 + } + return 1 +} + +// rateUnitForSeconds reverses rateUnitSeconds. +func (f *NFT) rateUnitForSeconds(s uint64) (RateUnit, bool) { + switch s { + case 1: + return PerSecond, true + case 60: + return PerMinute, true + case 3600: + return PerHour, true + case 86400: + return PerDay, true + } + return PerSecond, false +} + +// addrBytes renders an address or CIDR as the network-header operand a match +// compares against: the raw address bytes plus, for a prefix, the mask to apply +// first. A host address yields a nil mask. +func (f *NFT) addrBytes(fam Family, addr string) (value, mask []byte, err error) { + width := 4 + if fam == IPv6 { + width = 16 + } + if _, ipnet, cerr := net.ParseCIDR(addr); cerr == nil { + ones, bits := ipnet.Mask.Size() + if bits/8 != width { + return nil, nil, fmt.Errorf("address %q does not match the rule's family", addr) + } + network := ipnet.IP.To16() + if width == 4 { + network = ipnet.IP.To4() + } + if ones == bits { + // A host prefix is the address itself; no masking needed. + return network, nil, nil + } + return network, ipnet.Mask, nil + } + ip := net.ParseIP(addr) + if ip == nil { + return nil, nil, fmt.Errorf("invalid address %q", addr) + } + if width == 4 { + v4 := ip.To4() + if v4 == nil { + return nil, nil, fmt.Errorf("address %q is not IPv4", addr) + } + return v4, nil, nil + } + if ip.To4() != nil { + return nil, nil, fmt.Errorf("address %q is not IPv6", addr) + } + return ip.To16(), nil, nil +} + +// cmpOp returns the comparison a match uses, inverted when the value was negated. +func (f *NFT) cmpOp(neg bool) expr.CmpOp { + if neg { + return expr.CmpOpNeq + } + return expr.CmpOpEq +} + +// encodeAddr appends the expressions matching a source or destination address. +// A named set is referenced by a lookup; an address or CIDR loads the header +// field and compares it, masking first when the value is a prefix. +func (f *NFT) encodeAddr(exprs []expr.Any, fam Family, value string, source bool) ([]expr.Any, error) { + neg, bare := splitAddrNeg(strings.TrimSpace(value)) + offset, length := addrField(fam, source) + exprs = append(exprs, &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: offset, + Len: length, + }) + if isSetRef(value) { + exprs = append(exprs, &expr.Lookup{ + SourceRegister: 1, + SetName: bare, + Invert: neg, + }) + return exprs, nil + } + val, mask, err := f.addrBytes(fam, bare) + if err != nil { + return nil, err + } + if mask != nil { + // A prefix match is the masked field compared against the network + // address. nft shortens a byte-aligned prefix to a narrower payload load + // instead; both forms are accepted on read, and this one covers every + // prefix length with a single shape. + exprs = append(exprs, &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: uint32(len(val)), + Mask: mask, + Xor: make([]byte, len(val)), + }) + } + exprs = append(exprs, &expr.Cmp{Op: f.cmpOp(neg), Register: 1, Data: val}) + return exprs, nil +} + +// portElements renders port ranges as the elements of an anonymous set. An +// interval element is the inclusive start plus an end marker at the exclusive +// upper bound, which is how nftables stores a span. +func (f *NFT) portElements(specs []PortRange, interval bool) []nftables.SetElement { + var elems []nftables.SetElement + for _, p := range specs { + elems = append(elems, nftables.SetElement{Key: binaryutil.BigEndian.PutUint16(p.Start)}) + if interval { + elems = append(elems, nftables.SetElement{ + Key: binaryutil.BigEndian.PutUint16(p.End + 1), + IntervalEnd: true, + }) + } + } + return elems +} + +// encodePorts appends the expressions matching a source or destination port. A +// single discrete port compares directly, a single span is a range, and a list +// becomes an anonymous set (an interval set when any member is a span). +func (f *NFT) encodePorts(enc *nftEncoded, specs []PortRange, source bool) { + offset := uint32(2) + if source { + offset = 0 + } + enc.exprs = append(enc.exprs, &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: offset, + Len: 2, + }) + + if len(specs) == 1 { + p := specs[0] + if p.Start == p.End { + enc.exprs = append(enc.exprs, &expr.Cmp{ + Op: expr.CmpOpEq, Register: 1, Data: binaryutil.BigEndian.PutUint16(p.Start), + }) + return + } + enc.exprs = append(enc.exprs, &expr.Range{ + Op: expr.CmpOpEq, + Register: 1, + FromData: binaryutil.BigEndian.PutUint16(p.Start), + ToData: binaryutil.BigEndian.PutUint16(p.End), + }) + return + } + + interval := false + for _, p := range specs { + if p.Start != p.End { + interval = true + break + } + } + set := &nftables.Set{ + Table: f.tableRef(), + ID: f.nextSetID(), + Name: "__set%d", + Anonymous: true, + Constant: true, + Interval: interval, + KeyType: nftables.TypeInetService, + } + enc.anonSets = append(enc.anonSets, nftAnonSet{set: set, elements: f.portElements(specs, interval)}) + enc.exprs = append(enc.exprs, &expr.Lookup{SourceRegister: 1, SetName: set.Name, SetID: set.ID}) +} + +// encodeTCPUDP appends the both-transports protocol match: an anonymous set of +// the two protocol numbers, which keeps a TCPUDP rule a single nftables row. +func (f *NFT) encodeTCPUDP(enc *nftEncoded) { + set := &nftables.Set{ + Table: f.tableRef(), + ID: f.nextSetID(), + Name: "__set%d", + Anonymous: true, + Constant: true, + KeyType: nftables.TypeInetProto, + } + enc.anonSets = append(enc.anonSets, nftAnonSet{set: set, elements: []nftables.SetElement{ + {Key: []byte{unix.IPPROTO_TCP}}, + {Key: []byte{unix.IPPROTO_UDP}}, + }}) + enc.exprs = append(enc.exprs, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Lookup{SourceRegister: 1, SetName: set.Name, SetID: set.ID}, + ) +} + +// validateRule reports whether the rule is valid for nftables, applying the +// universal Rule.validate and then this encoder's shape constraints. The +// encoding entry points run it before marshalling; MarshalRule itself is a pure +// encoder. Unlike the other backends it does not reject TCPUDP: nft carries both +// transports in one row. +func (f *NFT) validateRule(r *Rule) error { + if err := r.validate(); err != nil { + return err + } + // A comment rides in the rule's user data and a log prefix in the log + // expression; both are length-capped by nftables and the kernel. + if len(r.Comment) > nftCommentMax { + return fmt.Errorf("an nftables comment may not exceed %d bytes", nftCommentMax) + } + if len(r.LogPrefix) > nftLogPrefixMax { + return fmt.Errorf("an nftables log prefix may not exceed %d bytes", nftLogPrefixMax) + } + // A per-source connection limit counts in a family-typed meter (its key is + // the source address field), so a FamilyAny rule must be expanded to concrete + // families by the caller before reaching the marshaller. + if r.ConnLimit != nil && r.ConnLimit.PerSource && r.impliedFamily() == FamilyAny { + return fmt.Errorf("a per-source connection limit counts in a family-typed meter; the caller must expand the rule to concrete families first") + } + // A connection-state match must name states nftables knows. + if r.State != 0 && f.ctStateMask(r.State) == 0 { + return fmt.Errorf("no valid connection state was provided") + } + // The rule must carry a valid verdict. + switch r.Action { + case Accept, Drop, Reject: + default: + return fmt.Errorf("no valid action was provided") + } + return nil +} + +// MarshalRule encodes a filter rule as the nftables expression list the kernel +// stores, plus the anonymous sets and meter set it references. It is a pure +// encoder: callers run validateRule first. +func (f *NFT) MarshalRule(r *Rule) (*nftEncoded, error) { + enc := &nftEncoded{chain: f.chainForDirection(r.Direction)} + fam := r.impliedFamily() + + // Interface match. + if r.InInterface != "" { + enc.exprs = append(enc.exprs, + &expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: f.ifnameBytes(r.InInterface)}, + ) + } + if r.OutInterface != "" { + enc.exprs = append(enc.exprs, + &expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: f.ifnameBytes(r.OutInterface)}, + ) + } + + // Family pin. In an inet table a network-header offset means different fields + // in the two families, so every rule that resolves to a concrete family states + // it — not only the address-less ones. Without the guard an IPv4 source-address + // load also matches inside an IPv6 source address. + if fam != FamilyAny { + enc.exprs = append(enc.exprs, + &expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{f.nfprotoByte(fam)}}, + ) + } + + // Address matches, honoring negation and named-set references. + var err error + if r.Source != "" { + if enc.exprs, err = f.encodeAddr(enc.exprs, fam, r.Source, true); err != nil { + return nil, err + } + } + if r.Destination != "" { + if enc.exprs, err = f.encodeAddr(enc.exprs, fam, r.Destination, false); err != nil { + return nil, err + } + } + + // Protocol and port matches. A TCPUDP rule pins both transports with an + // anonymous set and matches its ports through the shared transport-header + // offsets, which is valid precisely because l4proto is constrained to + // port-carrying protocols; that keeps the rule a single row needing no + // fan-out. Every other protocol names itself before its ports. + srcSpecs := r.SourcePortSpecs() + hasPorts := r.HasPorts() || len(srcSpecs) > 0 + switch { + case r.Proto == TCPUDP: + f.encodeTCPUDP(enc) + case r.Proto != ProtocolAny: + pb, ok := f.ipProtoByte(r.Proto) + if !ok { + return nil, fmt.Errorf("unsupported protocol %s", r.Proto) + } + enc.exprs = append(enc.exprs, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{pb}}, + ) + case hasPorts: + // A port match loads the transport header, so it needs a protocol to load + // it from. Rule.validate rejects this at the entry points; the encoder + // repeats it because the internal split paths marshal directly. + return nil, fmt.Errorf("a port match requires a protocol") + } + if r.HasPorts() { + f.encodePorts(enc, r.PortSpecs(), false) + } + if len(srcSpecs) > 0 { + f.encodePorts(enc, srcSpecs, true) + } + if r.Proto.IsICMP() && r.ICMPType != nil { + // The message type is the first byte of the ICMP header. + enc.exprs = append(enc.exprs, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseTransportHeader, + Offset: 0, + Len: 1, + }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{*r.ICMPType}}, + ) + } + + // Connection-tracking state: the state register is masked to the requested + // states and matches when any of them is set. + if r.State != 0 { + enc.exprs = append(enc.exprs, + &expr.Ct{Key: expr.CtKeySTATE, Register: 1}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: binaryutil.NativeEndian.PutUint32(f.ctStateMask(r.State)), + Xor: []byte{0, 0, 0, 0}, + }, + &expr.Cmp{Op: expr.CmpOpNeq, Register: 1, Data: []byte{0, 0, 0, 0}}, + ) + } + + // Rate limit: the statement matches only while under the rate, so over-rate + // packets fall through to later rules rather than taking this rule's verdict. + if r.RateLimit != nil { + burst := uint32(r.RateLimit.Burst) + if burst == 0 { + burst = netfilterDefaultBurst + } + enc.exprs = append(enc.exprs, &expr.Limit{ + Type: expr.LimitTypePkts, + Rate: uint64(r.RateLimit.Rate), + Unit: expr.LimitTime(rateUnitSeconds(r.RateLimit.Unit)), + Burst: burst, + }) + } + + // Connection limit. Per-source counting keys the count on the source address + // in a named dynamic set — the set is family-typed, so a FamilyAny rule was + // fanned out into one row per family before reaching here. + if r.ConnLimit != nil { + if r.ConnLimit.PerSource { + name := f.meterName(enc.chain, r) + keyType := nftables.TypeIPAddr + if fam == IPv6 { + keyType = nftables.TypeIP6Addr + } + enc.meterSet = &nftables.Set{ + Table: f.tableRef(), + Name: name, + KeyType: keyType, + Dynamic: true, + Size: nftMeterSetSize, + } + offset, length := addrField(fam, true) + enc.exprs = append(enc.exprs, + &expr.Payload{ + DestRegister: 1, + Base: expr.PayloadBaseNetworkHeader, + Offset: offset, + Len: length, + }, + &expr.Dynset{ + SrcRegKey: 1, + SetName: name, + Operation: uint32(unix.NFT_DYNSET_OP_ADD), + Exprs: []expr.Any{ + &expr.Connlimit{Count: uint32(r.ConnLimit.Count), Flags: expr.NFT_CONNLIMIT_F_INV}, + }, + }, + ) + } else { + enc.exprs = append(enc.exprs, &expr.Connlimit{ + Count: uint32(r.ConnLimit.Count), + Flags: expr.NFT_CONNLIMIT_F_INV, + }) + } + } + + // Logging, emitted just before the verdict so the packet is logged and then + // the action is applied. + if r.Log { + lg := &expr.Log{Level: expr.LogLevelWarning} + if r.LogPrefix != "" { + lg.Key = 1 << unix.NFTA_LOG_PREFIX + lg.Data = []byte(r.LogPrefix) + } + enc.exprs = append(enc.exprs, lg) + } + + // A counter so GetRules can report per-rule packet/byte statistics. The + // counter has no effect on matching and is ignored when comparing rules. + enc.exprs = append(enc.exprs, &expr.Counter{}) + + // Verdict. validateRule has already rejected an invalid action. + switch r.Action { + case Accept: + enc.exprs = append(enc.exprs, &expr.Verdict{Kind: expr.VerdictAccept}) + case Drop: + enc.exprs = append(enc.exprs, &expr.Verdict{Kind: expr.VerdictDrop}) + case Reject: + // The inet table's reject default: an ICMPX port-unreachable, which the + // kernel renders per family. + enc.exprs = append(enc.exprs, &expr.Reject{ + Type: unix.NFT_REJECT_ICMPX_UNREACH, + Code: unix.NFT_REJECT_ICMPX_PORT_UNREACH, + }) + } + + // An optional user comment, stored as nftables user data. Unlike the textual + // interface this has no quoting, so any comment within the length cap round + // trips verbatim. + if r.Comment != "" { + enc.userData = userdata.AppendString(nil, userdata.TypeComment, r.Comment) + } + + return enc, nil +} + +// validateNAT reports whether the NAT rule is valid for nftables, applying the +// universal NATRule.validate and then this encoder's constraints. The encoding +// entry points run it before marshalling; MarshalNATRule is a pure encoder. +func (f *NFT) validateNAT(r *NATRule) error { + if err := r.validate(); err != nil { + return err + } + // nft's snat expression maps only to an address; a source-port translation has + // no representation here (iptables emits it as --to-source addr:port). + if r.Kind == SNAT && r.ToPort != 0 { + return fmt.Errorf("nftables snat does not translate the source port: %w", ErrUnsupportedNAT) + } + return nil +} + +// MarshalNATRule encodes a NAT rule as an expression list, returning it in the +// chain it belongs in (prerouting for destination NAT, postrouting for source +// NAT). It is a pure encoder: callers run validateNAT first. +func (f *NFT) MarshalNATRule(r *NATRule) (*nftEncoded, error) { + enc := &nftEncoded{chain: "prerouting"} + if r.Kind.isSource() { + enc.chain = "postrouting" + } + fam := r.impliedFamily() + + // Interface, bound to the NAT direction: outbound for source NAT, inbound + // for destination NAT. + if r.Interface != "" { + key := expr.MetaKeyIIFNAME + if r.Kind.isSource() { + key = expr.MetaKeyOIFNAME + } + enc.exprs = append(enc.exprs, + &expr.Meta{Key: key, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: f.ifnameBytes(r.Interface)}, + ) + } + + // Family pin, for the same reason as a filter rule. + if fam != FamilyAny { + enc.exprs = append(enc.exprs, + &expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{f.nfprotoByte(fam)}}, + ) + } + + var err error + if r.Source != "" { + if enc.exprs, err = f.encodeAddr(enc.exprs, fam, r.Source, true); err != nil { + return nil, err + } + } + if r.Destination != "" { + if enc.exprs, err = f.encodeAddr(enc.exprs, fam, r.Destination, false); err != nil { + return nil, err + } + } + + if r.Proto != ProtocolAny { + pb, ok := f.ipProtoByte(r.Proto) + if !ok { + return nil, fmt.Errorf("unsupported protocol %s", r.Proto) + } + enc.exprs = append(enc.exprs, + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{pb}}, + ) + } + if r.HasPorts() { + f.encodePorts(enc, r.PortSpecs(), false) + } + + // The translation. An address goes into register 1 and a port into register + // 2, which the nat expression then names; redirect takes only a port and + // reads it from register 1. + switch r.Kind { + case DNAT, SNAT: + natType := expr.NATTypeDestNAT + if r.Kind == SNAT { + natType = expr.NATTypeSourceNAT + } + n := &expr.NAT{Type: natType, Family: uint32(f.nfprotoByte(fam))} + if r.ToAddress != "" { + val, mask, aerr := f.addrBytes(fam, r.ToAddress) + if aerr != nil { + return nil, aerr + } + if mask != nil { + return nil, fmt.Errorf("a nat translation target must be a single address, not a prefix") + } + enc.exprs = append(enc.exprs, &expr.Immediate{Register: 1, Data: val}) + n.RegAddrMin, n.RegAddrMax = 1, 1 + } + if r.ToPort != 0 { + enc.exprs = append(enc.exprs, &expr.Immediate{ + Register: 2, Data: binaryutil.BigEndian.PutUint16(r.ToPort), + }) + n.RegProtoMin, n.RegProtoMax = 2, 2 + } + enc.exprs = append(enc.exprs, n) + case Redirect: + rd := &expr.Redir{} + if r.ToPort != 0 { + enc.exprs = append(enc.exprs, &expr.Immediate{ + Register: 1, Data: binaryutil.BigEndian.PutUint16(r.ToPort), + }) + rd.RegisterProtoMin, rd.RegisterProtoMax = 1, 1 + rd.Flags = unix.NF_NAT_RANGE_PROTO_SPECIFIED + } + enc.exprs = append(enc.exprs, rd) + case Masquerade: + enc.exprs = append(enc.exprs, &expr.Masq{}) + } + + return enc, nil +} + +// meterName derives the dynamic-set name a per-source connection-limit rule +// counts in. The name is a hash of the rule's identity (chain, family, match, +// limit and verdict) so two distinct per-source rules never share counting +// state, while a re-add — or a split's re-add — of the same rule deterministically +// reuses its set. The comment is excluded, as it is not part of rule identity. +func (f *NFT) meterName(chain string, r *Rule) string { + h := fnv.New64a() + _, _ = fmt.Fprintf(h, "%s|%d|%d|%s|%s|%d|%s|%s|%v|%d|%d|%d", + chain, r.impliedFamily(), r.Proto, r.Source, r.Destination, + r.State, FormatPortRanges(r.PortSpecs(), ","), FormatPortRanges(r.SourcePortSpecs(), ","), + r.Log, r.Action, r.ConnLimit.Count, r.Priority) + return fmt.Sprintf("cl%016x", h.Sum64()) +} + +// perSourceFamilySplit reports whether a rule must be fanned out into one row +// per family before marshalling: a per-source connection limit counts in a +// family-typed dynamic set, so a FamilyAny rule has no single nftables row — +// the family analog of the DirAny fan-out. +func (f *NFT) perSourceFamilySplit(r *Rule) bool { + return r.perSourceLimited() && r.impliedFamily() == FamilyAny +} + +// ----------------------------------------------------------------------------- +// Expression decoding +// ----------------------------------------------------------------------------- + +// nftSetContents is a set's definition together with its elements, which a +// lookup expression must be resolved against to recover the values it matches. +type nftSetContents struct { + set *nftables.Set + elements []nftables.SetElement +} + +// nftSetReader resolves the sets a rule's lookups reference, caching each read +// so decoding a chain full of set-referencing rules costs one dump per set. +type nftSetReader struct { + conn *nftables.Conn + cache map[string]*nftSetContents + // staged holds sets that exist only in an in-flight batch, keyed by the + // transaction-local identifier a lookup names them by. Every anonymous set + // shares the same placeholder name until the kernel resolves it, so an + // encoding can only be decoded back through its identifiers. + staged map[uint32]*nftSetContents +} + +// newSetReader returns a set resolver bound to a netlink connection. +func newSetReader(c *nftables.Conn) *nftSetReader { + return &nftSetReader{conn: c, cache: make(map[string]*nftSetContents)} +} + +// newStagedSetReader returns a resolver over the anonymous sets encoded rules +// carry, so an encoding can be decoded back without ever reaching the kernel. +func newStagedSetReader(encs ...*nftEncoded) *nftSetReader { + s := &nftSetReader{staged: make(map[uint32]*nftSetContents)} + for _, enc := range encs { + for _, as := range enc.anonSets { + s.staged[as.set.ID] = &nftSetContents{set: as.set, elements: as.elements} + } + } + return s +} + +// resolve reads the set a lookup names. A set staged in the current batch is +// named by its transaction-local identifier; one already in the ruleset is named +// by the name the kernel assigned it. +func (s *nftSetReader) resolve(tbl *nftables.Table, lk *expr.Lookup) (*nftSetContents, error) { + if lk.SetID != 0 { + if sc, ok := s.staged[lk.SetID]; ok { + return sc, nil + } + } + if s.conn == nil { + return nil, fmt.Errorf("set %q is not available", lk.SetName) + } + key := fmt.Sprintf("%d|%s|%s", tbl.Family, tbl.Name, lk.SetName) + if c, ok := s.cache[key]; ok { + return c, nil + } + set, err := s.conn.GetSetByName(tbl, lk.SetName) + if err != nil { + return nil, err + } + elems, err := s.conn.GetSetElements(set) + if err != nil { + return nil, err + } + c := &nftSetContents{set: set, elements: elems} + s.cache[key] = c + return c, nil +} + +// portRangesFromSet reconstructs the port ranges a set holds. A plain set lists +// discrete ports; an interval set lists boundary markers, each span being an +// inclusive start followed by an end marker at the exclusive upper bound. +func (f *NFT) portRangesFromSet(sc *nftSetContents) ([]PortRange, error) { + type elem struct { + port uint16 + end bool + } + var elems []elem + for _, e := range sc.elements { + if len(e.Key) != 2 { + return nil, fmt.Errorf("unsupported port set element width %d", len(e.Key)) + } + elems = append(elems, elem{port: binaryutil.BigEndian.Uint16(e.Key), end: e.IntervalEnd}) + } + sort.Slice(elems, func(i, j int) bool { + if elems[i].port == elems[j].port { + return !elems[i].end && elems[j].end + } + return elems[i].port < elems[j].port + }) + + var specs []PortRange + if !sc.set.Interval { + for _, e := range elems { + specs = append(specs, PortRange{Start: e.port, End: e.port}) + } + return specs, nil + } + open := false + var start uint16 + for _, e := range elems { + if !e.end { + start, open = e.port, true + continue + } + if !open { + // A leading end marker closes the span below the first element, which + // carries no range of its own. + continue + } + if e.port == 0 { + return nil, fmt.Errorf("invalid interval end 0 in port set") + } + specs = append(specs, PortRange{Start: start, End: e.port - 1}) + open = false + } + if open { + // An unterminated span runs to the top of the port space. + specs = append(specs, PortRange{Start: start, End: 65535}) + } + return specs, nil +} + +// protocolFromSet decodes an l4proto lookup. The only set this backend writes, +// and the only one a single Proto field can hold, is the both-transports pair; +// anything else belongs to a foreign rule whose coverage the model cannot carry, +// so it is rejected rather than narrowed to one member. +func (f *NFT) protocolFromSet(sc *nftSetContents) (Protocol, error) { + if len(sc.elements) != 2 { + return ProtocolAny, fmt.Errorf("unsupported l4proto set of %d members", len(sc.elements)) + } + var got [2]Protocol + for i, e := range sc.elements { + if len(e.Key) != 1 { + return ProtocolAny, fmt.Errorf("unsupported l4proto set element width %d", len(e.Key)) + } + got[i] = f.protocolForByte(e.Key[0]) + } + if (got[0] == TCP && got[1] == UDP) || (got[0] == UDP && got[1] == TCP) { + return TCPUDP, nil + } + return ProtocolAny, fmt.Errorf("unsupported l4proto set") +} + +// maskPrefixLen returns the prefix length a contiguous network mask represents, +// reporting false for a non-contiguous mask the address model cannot render. +func (f *NFT) maskPrefixLen(mask []byte) (int, bool) { + ones, bits := net.IPMask(mask).Size() + if bits == 0 { + return 0, false + } + return ones, true +} + +// addrFromPayload reconstructs the address or CIDR a network-header match names. +// It accepts both shapes nftables stores: a full-width load with an optional +// mask, and the shortened load nft emits for a byte-aligned prefix. +func (f *NFT) addrFromPayload(fam Family, p *expr.Payload, mask, data []byte) (string, error) { + width := 4 + if fam == IPv6 { + width = 16 + } + if int(p.Len) > width || len(data) != int(p.Len) { + return "", fmt.Errorf("unsupported address match width %d", p.Len) + } + + full := make([]byte, width) + copy(full, data) + ip := net.IP(full) + + switch { + case int(p.Len) < width: + // A shortened load compares only the leading bytes: a byte-aligned prefix. + return (&net.IPNet{IP: ip, Mask: net.CIDRMask(int(p.Len)*8, width*8)}).String(), nil + case mask != nil: + ones, ok := f.maskPrefixLen(mask) + if !ok { + return "", fmt.Errorf("unsupported non-contiguous address mask") + } + if ones == width*8 { + return ip.String(), nil + } + return (&net.IPNet{IP: ip, Mask: net.CIDRMask(ones, width*8)}).String(), nil + default: + return ip.String(), nil + } +} + +// nftDecoder walks a rule's expression list, pairing each value-producing +// expression (a meta, payload or ct load) with the test that follows it. +type nftDecoder struct { + f *NFT + sets *nftSetReader + tbl *nftables.Table + fam Family + // load is the most recent value-producing expression, awaiting its test. + load expr.Any + // mask is a pending bitwise mask applied to the loaded value. + mask []byte +} + +// setPort stores a decoded port match, keeping the single-port form for a lone +// discrete port so it round-trips against rules built that way. +func (f *NFT) setPort(r *Rule, specs []PortRange, source bool) { + single := len(specs) == 1 && specs[0].Start == specs[0].End + if source { + if single { + r.SourcePort = specs[0].Start + } else { + r.SourcePorts = specs + } + return + } + if single { + r.Port = specs[0].Start + } else { + r.Ports = specs + } +} + +// applyCmp folds a comparison into the rule, interpreting it against the value +// the preceding load produced. +func (d *nftDecoder) applyCmp(r *Rule, c *expr.Cmp) error { + neg := c.Op == expr.CmpOpNeq + switch l := d.load.(type) { + case *expr.Meta: + switch l.Key { + case expr.MetaKeyNFPROTO: + if len(c.Data) != 1 { + return fmt.Errorf("unsupported nfproto match") + } + fam, ok := d.f.familyForNFProto(c.Data[0]) + if !ok { + return fmt.Errorf("unsupported nfproto %d", c.Data[0]) + } + r.Family, d.fam = fam, fam + case expr.MetaKeyL4PROTO: + if len(c.Data) != 1 { + return fmt.Errorf("unsupported l4proto match") + } + p := d.f.protocolForByte(c.Data[0]) + if p == ProtocolAny { + return fmt.Errorf("unsupported l4proto %d", c.Data[0]) + } + r.Proto = p + case expr.MetaKeyIIFNAME: + r.InInterface = d.f.ifnameString(c.Data) + case expr.MetaKeyOIFNAME: + r.OutInterface = d.f.ifnameString(c.Data) + default: + return fmt.Errorf("unsupported meta key %d", l.Key) + } + case *expr.Payload: + switch l.Base { + case expr.PayloadBaseNetworkHeader: + if d.fam == FamilyAny { + // Without a family guard a network-header offset is ambiguous + // between the two families in an inet table. + return fmt.Errorf("address match without a family qualifier") + } + soff, _ := addrField(d.fam, true) + doff, _ := addrField(d.fam, false) + addr, aerr := d.f.addrFromPayload(d.fam, l, d.mask, c.Data) + if aerr != nil { + return aerr + } + if neg { + addr = "!" + addr + } + switch l.Offset { + case soff: + r.Source = addr + case doff: + r.Destination = addr + default: + return fmt.Errorf("unsupported network header offset %d", l.Offset) + } + case expr.PayloadBaseTransportHeader: + switch { + case l.Offset == 2 && l.Len == 2: + d.f.setPort(r, []PortRange{{Start: binaryutil.BigEndian.Uint16(c.Data), End: binaryutil.BigEndian.Uint16(c.Data)}}, false) + case l.Offset == 0 && l.Len == 2: + d.f.setPort(r, []PortRange{{Start: binaryutil.BigEndian.Uint16(c.Data), End: binaryutil.BigEndian.Uint16(c.Data)}}, true) + case l.Offset == 0 && l.Len == 1: + // The ICMP message type. + if !r.Proto.IsICMP() { + return fmt.Errorf("an icmp type match requires an icmp protocol") + } + r.ICMPType = Ptr(c.Data[0]) + default: + return fmt.Errorf("unsupported transport header offset %d width %d", l.Offset, l.Len) + } + default: + return fmt.Errorf("unsupported payload base %d", l.Base) + } + case *expr.Ct: + if l.Key != expr.CtKeySTATE { + return fmt.Errorf("unsupported ct key %d", l.Key) + } + if d.mask == nil || len(d.mask) != 4 || c.Op != expr.CmpOpNeq { + return fmt.Errorf("unsupported ct state match") + } + state, ok := d.f.connStateForMask(binaryutil.NativeEndian.Uint32(d.mask)) + if !ok { + return fmt.Errorf("unsupported ct state mask") + } + r.State = state + default: + return fmt.Errorf("comparison without a value to compare") + } + return nil +} + +// applyLookup folds a set lookup into the rule. +func (d *nftDecoder) applyLookup(r *Rule, lk *expr.Lookup) error { + switch l := d.load.(type) { + case *expr.Meta: + if l.Key != expr.MetaKeyL4PROTO { + return fmt.Errorf("unsupported meta set lookup") + } + sc, err := d.sets.resolve(d.tbl, lk) + if err != nil { + return err + } + p, err := d.f.protocolFromSet(sc) + if err != nil { + return err + } + r.Proto = p + case *expr.Payload: + switch l.Base { + case expr.PayloadBaseNetworkHeader: + if d.fam == FamilyAny { + return fmt.Errorf("address set lookup without a family qualifier") + } + soff, _ := addrField(d.fam, true) + doff, _ := addrField(d.fam, false) + name := lk.SetName + if lk.Invert { + name = "!" + name + } + switch l.Offset { + case soff: + r.Source = name + case doff: + r.Destination = name + default: + return fmt.Errorf("unsupported network header offset %d", l.Offset) + } + case expr.PayloadBaseTransportHeader: + if l.Len != 2 || (l.Offset != 0 && l.Offset != 2) { + return fmt.Errorf("unsupported port set lookup") + } + sc, err := d.sets.resolve(d.tbl, lk) + if err != nil { + return err + } + specs, err := d.f.portRangesFromSet(sc) + if err != nil { + return err + } + if len(specs) == 0 { + return fmt.Errorf("empty port set") + } + d.f.setPort(r, specs, l.Offset == 0) + default: + return fmt.Errorf("unsupported payload base %d", l.Base) + } + default: + return fmt.Errorf("set lookup without a value to look up") + } + return nil +} + +// applyRange folds a range test into the rule; nftables uses one for a single +// port span. +func (d *nftDecoder) applyRange(r *Rule, rg *expr.Range) error { + l, ok := d.load.(*expr.Payload) + if !ok || l.Base != expr.PayloadBaseTransportHeader || l.Len != 2 { + return fmt.Errorf("unsupported range match") + } + if l.Offset != 0 && l.Offset != 2 { + return fmt.Errorf("unsupported range offset %d", l.Offset) + } + if rg.Op != expr.CmpOpEq || len(rg.FromData) != 2 || len(rg.ToData) != 2 { + return fmt.Errorf("unsupported range match") + } + d.f.setPort(r, []PortRange{{ + Start: binaryutil.BigEndian.Uint16(rg.FromData), + End: binaryutil.BigEndian.Uint16(rg.ToData), + }}, l.Offset == 0) + return nil +} + +// applyDynset folds a per-source connection limit into the rule. The dynamic set +// is keyed on the source address loaded just before it, which is what pins the +// rule's family. +func (d *nftDecoder) applyDynset(r *Rule, ds *expr.Dynset) error { + var cl *expr.Connlimit + for _, inner := range ds.Exprs { + if c, ok := inner.(*expr.Connlimit); ok { + cl = c + } + } + if cl == nil { + return fmt.Errorf("unsupported dynamic set statement") + } + if cl.Flags&expr.NFT_CONNLIMIT_F_INV == 0 { + return fmt.Errorf("unsupported under-limit connection count") + } + l, ok := d.load.(*expr.Payload) + if !ok || l.Base != expr.PayloadBaseNetworkHeader { + return fmt.Errorf("unsupported connection-limit key") + } + if d.fam == FamilyAny { + return fmt.Errorf("connection-limit key without a family qualifier") + } + soff, slen := addrField(d.fam, true) + if l.Offset != soff || l.Len != slen { + return fmt.Errorf("unsupported connection-limit key offset %d", l.Offset) + } + r.ConnLimit = &ConnLimit{Count: uint(cl.Count), PerSource: true} + r.meterSet = ds.SetName + return nil +} + +// UnmarshalRule decodes a chain row's expression list into a filter rule. It +// returns an error for any row carrying a construct the model cannot hold, so +// the caller can keep the row as an opaque slot rather than misrepresenting it. +func (f *NFT) UnmarshalRule(nr *nftables.Rule, chain string, sets *nftSetReader, tbl *nftables.Table) (*Rule, error) { + r := &Rule{Direction: f.directionForChain(chain)} + d := &nftDecoder{f: f, sets: sets, tbl: tbl} + // A row in a single-family table states no nfproto of its own: the table is + // the qualifier, so seed the family from it. Without this an operator's + // `table ip filter` rule matching an address (or an address set) has no family + // to read its network-header offsets against and the whole row goes opaque. + if fam, ok := f.familyForTable(tbl); ok { + r.Family, d.fam = fam, fam + } + + for _, e := range nr.Exprs { + switch v := e.(type) { + case *expr.Meta, *expr.Payload, *expr.Ct: + d.load, d.mask = v, nil + case *expr.Bitwise: + d.mask = v.Mask + case *expr.Cmp: + if err := d.applyCmp(r, v); err != nil { + return nil, err + } + case *expr.Lookup: + if err := d.applyLookup(r, v); err != nil { + return nil, err + } + case *expr.Range: + if err := d.applyRange(r, v); err != nil { + return nil, err + } + case *expr.Dynset: + if err := d.applyDynset(r, v); err != nil { + return nil, err + } + case *expr.Connlimit: + if v.Flags&expr.NFT_CONNLIMIT_F_INV == 0 { + return nil, fmt.Errorf("unsupported under-limit connection count") + } + r.ConnLimit = &ConnLimit{Count: uint(v.Count)} + case *expr.Limit: + if v.Type != expr.LimitTypePkts || v.Over { + return nil, fmt.Errorf("unsupported limit statement") + } + unit, ok := f.rateUnitForSeconds(uint64(v.Unit)) + if !ok { + return nil, fmt.Errorf("unsupported rate unit %d", v.Unit) + } + // nftables applies a default burst of 5 packets to every limit and + // reports it back even when none was requested, so the default reads + // as unset. + r.RateLimit = &RateLimit{Rate: uint(v.Rate), Unit: unit, Burst: normBurst(uint(v.Burst))} + case *expr.Log: + r.Log = true + if len(v.Data) > 0 { + r.LogPrefix = string(v.Data) + } + case *expr.Counter: + r.Packets, r.Bytes = v.Packets, v.Bytes + case *expr.Verdict: + switch v.Kind { + case expr.VerdictAccept: + r.Action = Accept + case expr.VerdictDrop: + r.Action = Drop + default: + return nil, fmt.Errorf("unsupported verdict %d", v.Kind) + } + case *expr.Reject: + r.Action = Reject + default: + return nil, fmt.Errorf("unsupported expression %T", e) + } + } + + if r.Action == ActionInvalid { + return nil, fmt.Errorf("no valid action was provided") + } + // The comment rides in the rule's user data rather than its expressions. + if comment, ok := userdata.GetString(nr.UserData, userdata.TypeComment); ok { + r.Comment = comment + } + return r, nil +} + +// UnmarshalNATRule decodes a nat chain row's expression list into a NAT rule. +func (f *NFT) UnmarshalNATRule(nr *nftables.Rule, sets *nftSetReader, tbl *nftables.Table) (*NATRule, error) { + r := &NATRule{} + // The NAT matches reuse the filter decoder, which works against a Rule; the + // shared fields are copied across once the walk is done. + match := &Rule{} + d := &nftDecoder{f: f, sets: sets, tbl: tbl} + immediates := map[uint32][]byte{} + // The table settles the family for a single-family table's rows, as in + // UnmarshalRule; a nat expression naming its own family overrides it below. + if fam, ok := f.familyForTable(tbl); ok { + match.Family, d.fam = fam, fam + } + + for _, e := range nr.Exprs { + switch v := e.(type) { + case *expr.Meta, *expr.Payload, *expr.Ct: + d.load, d.mask = v, nil + case *expr.Bitwise: + d.mask = v.Mask + case *expr.Cmp: + if err := d.applyCmp(match, v); err != nil { + return nil, err + } + case *expr.Lookup: + if err := d.applyLookup(match, v); err != nil { + return nil, err + } + case *expr.Range: + if err := d.applyRange(match, v); err != nil { + return nil, err + } + case *expr.Immediate: + immediates[v.Register] = v.Data + case *expr.Counter: + // A foreign nat rule may carry a counter; it is not part of the model. + case *expr.NAT: + r.Kind = DNAT + if v.Type == expr.NATTypeSourceNAT { + r.Kind = SNAT + } + if fam, ok := f.familyForNFProto(byte(v.Family)); ok { + r.Family = fam + } + if v.RegAddrMin != 0 { + data, ok := immediates[v.RegAddrMin] + if !ok { + return nil, fmt.Errorf("nat address register %d was never loaded", v.RegAddrMin) + } + r.ToAddress = net.IP(data).String() + } + if v.RegProtoMin != 0 { + data, ok := immediates[v.RegProtoMin] + if !ok || len(data) != 2 { + return nil, fmt.Errorf("nat port register %d was never loaded", v.RegProtoMin) + } + r.ToPort = binaryutil.BigEndian.Uint16(data) + } + case *expr.Redir: + r.Kind = Redirect + if v.RegisterProtoMin != 0 { + data, ok := immediates[v.RegisterProtoMin] + if !ok || len(data) != 2 { + return nil, fmt.Errorf("redirect port register %d was never loaded", v.RegisterProtoMin) + } + r.ToPort = binaryutil.BigEndian.Uint16(data) + } + case *expr.Masq: + r.Kind = Masquerade + default: + return nil, fmt.Errorf("unsupported expression %T", e) + } + } + + if r.Kind == NATInvalid { + return nil, fmt.Errorf("no nat action was provided") + } + + // Carry the decoded matches across. The interface is direction-bound, so + // whichever side the match named is the rule's interface. + if match.Family != FamilyAny { + r.Family = match.Family + } + r.Source, r.Destination = match.Source, match.Destination + r.Proto = match.Proto + r.Port, r.Ports = match.Port, match.Ports + if match.InInterface != "" { + r.Interface = match.InInterface + } else if match.OutInterface != "" { + r.Interface = match.OutInterface + } + if r.Family == FamilyAny { + r.Family = r.impliedFamily() + } + return r, nil +} + +// ----------------------------------------------------------------------------- +// Reading +// ----------------------------------------------------------------------------- + +// listChain returns the chain's rules with their nftables handles, 1:1 with its +// physical rows. A row the model cannot parse is kept as an opaque slot — a nil +// rule with its handle — so a rewrite deletes only rows the model understands +// and the position math stays aligned; GetRules and the dedup scans skip the +// nil entries. +func (f *NFT) listChain(c *nftables.Conn, sets *nftSetReader, chain string) (rules []*Rule, handles []uint64, err error) { + tbl := f.tableRef() + rows, err := c.GetRules(tbl, f.chainRef(chain)) + if err != nil { + // A missing table or chain simply means there are no rules yet. + if f.isNotExist(err) { + return nil, nil, nil + } + return nil, nil, err + } + for _, nr := range rows { + rule, perr := f.UnmarshalRule(nr, chain, sets, tbl) + if perr != nil { + rules = append(rules, nil) + handles = append(handles, nr.Handle) + continue + } + // Rules live in this backend's own table; membership in the library's + // private table is what sets HasPrefix, so record the table and flag it + // as carrying the prefix. + rule.table = f.table + rule.HasPrefix = true + rules = append(rules, rule) + handles = append(handles, nr.Handle) + } + return rules, handles, nil +} + +// listNATChain is listChain for the nat base chains. +func (f *NFT) listNATChain(c *nftables.Conn, sets *nftSetReader, chain string) (rules []*NATRule, handles []uint64, err error) { + tbl := f.tableRef() + rows, err := c.GetRules(tbl, f.chainRef(chain)) + if err != nil { + if f.isNotExist(err) { + return nil, nil, nil + } + return nil, nil, err + } + for _, nr := range rows { + rule, perr := f.UnmarshalNATRule(nr, sets, tbl) + if perr != nil { + rules = append(rules, nil) + handles = append(handles, nr.Handle) + continue + } + rule.table = f.table + rule.HasPrefix = true + rules = append(rules, rule) + handles = append(handles, nr.Handle) + } + return rules, handles, nil +} + +// foreignChains returns every chain in the ruleset that is not in this backend's +// own table, paired with the table it belongs to. Chains are dumped once per +// family rather than once per table. +func (f *NFT) foreignChains(c *nftables.Conn) ([]*nftables.Chain, error) { + tables, err := c.ListTables() + if err != nil { + return nil, err + } + families := map[nftables.TableFamily]bool{} + keep := map[string]bool{} + for _, t := range tables { + if t.Family == nftables.TableFamilyINet && t.Name == f.table { + continue + } + // A container runtime's table is out of scope entirely: it is the + // runtime's to reconcile, and this backend could not remove it anyway + // (mutations are scoped to our own table), so reporting it would only make + // Sync try, no-op, and over-count removed on every run. + if isContainerRuntimeTable(f.familyName(t.Family) + " " + t.Name) { + continue + } + families[t.Family] = true + keep[fmt.Sprintf("%d|%s", t.Family, t.Name)] = true + } + + var out []*nftables.Chain + for fam := range families { + chains, cerr := c.ListChainsOfTableFamily(fam) + if cerr != nil { + return nil, cerr + } + for _, ch := range chains { + if !keep[fmt.Sprintf("%d|%s", ch.Table.Family, ch.Table.Name)] { + continue + } + if isContainerRuntimeChain(ch.Name) { + continue + } + out = append(out, ch) + } + } + return out, nil +} + +// listForeignRules walks the ruleset and returns best-effort parsed rules that +// live outside this backend's own inet table. Because arbitrary foreign tables +// use constructs the Rule model cannot represent, any row that fails to decode +// is skipped rather than erroring the whole read. This gives callers visibility +// of rules in other tables alongside the library's own. +func (f *NFT) listForeignRules(c *nftables.Conn, sets *nftSetReader) ([]*Rule, error) { + chains, err := f.foreignChains(c) + if err != nil { + // No ruleset (or netlink unavailable for listing): nothing foreign to report. + return nil, nil + } + var rules []*Rule + for _, ch := range chains { + rows, rerr := c.GetRules(ch.Table, ch) + if rerr != nil { + continue + } + for _, nr := range rows { + rule, perr := f.UnmarshalRule(nr, ch.Name, sets, ch.Table) + if perr != nil || rule == nil { + continue + } + if rule.isContainerRuntime() { + continue + } + // A rule from another table: record where it came from; it is not ours, + // so HasPrefix stays false. + rule.table = f.familyName(ch.Table.Family) + " " + ch.Table.Name + rules = append(rules, rule) + } + } + return rules, nil +} + +// listForeignNATRules is listForeignRules for NAT rules. +func (f *NFT) listForeignNATRules(c *nftables.Conn, sets *nftSetReader) ([]*NATRule, error) { + chains, err := f.foreignChains(c) + if err != nil { + return nil, nil + } + var rules []*NATRule + for _, ch := range chains { + rows, rerr := c.GetRules(ch.Table, ch) + if rerr != nil { + continue + } + for _, nr := range rows { + rule, perr := f.UnmarshalNATRule(nr, sets, ch.Table) + if perr != nil || rule == nil { + continue + } + if rule.isHairpinMasquerade() { + continue + } + rule.table = f.familyName(ch.Table.Family) + " " + ch.Table.Name + rules = append(rules, rule) + } + } + return rules, nil +} + +// listOwnRules returns the library's own filter rules from its private table, one +// rule per physical chain row. A read does not create the table; listChain returns +// nothing when the table does not yet exist. nftables' inet table stores a +// family-agnostic rule as one unpinned row and a both-transports rule as one +// l4proto-set row, so UnmarshalRule reports FamilyAny and TCPUDP straight off the +// row that carries them; nothing is collapsed here. Number per direction (input +// then output chain) so each rule's Number matches the InsertRule/MoveRule +// position within its chain. +func (f *NFT) listOwnRules(c *nftables.Conn, sets *nftSetReader) ([]*Rule, error) { + var rules []*Rule + for _, chain := range nftFilterChains { + chainRules, _, cerr := f.listChain(c, sets, chain) + if cerr != nil { + return nil, cerr + } + // Opaque (nil) rows stay in the chain but are not reportable rules. + for _, r := range chainRules { + if r != nil { + rules = append(rules, r) + } + } + } + numberByDirection(rules) + return rules, nil +} + +// listOwnNATRules returns the library's own NAT rules from its private table, one +// rule per physical chain row. +func (f *NFT) listOwnNATRules(c *nftables.Conn, sets *nftSetReader) ([]*NATRule, error) { + var rules []*NATRule + for _, chain := range nftNATChains { + chainRules, _, cerr := f.listNATChain(c, sets, chain) + if cerr != nil { + return nil, cerr + } + for _, r := range chainRules { + if r != nil { + rules = append(rules, r) + } + } + } + // The nat chains live in the same inet table, so a family-agnostic translation is + // one unpinned row that reads back as FamilyAny; nothing is collapsed here. Number + // per nat chain (prerouting then postrouting) so each rule's Number matches the + // InsertNATRule/MoveNATRule position within its chain. + numberNATByChain(rules) + return rules, nil +} + +// GetRules returns the existing filter rules from the zone. +func (f *NFT) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + c, err := nftConn() + if err != nil { + return nil, err + } + sets := newSetReader(c) + + // The library's own rules, then foreign rules from every other table. + rules, err = f.listOwnRules(c, sets) + if err != nil { + return nil, err + } + foreign, ferr := f.listForeignRules(c, sets) + if ferr != nil { + return nil, ferr + } + rules = append(rules, foreign...) + return rules, nil +} + +// GetNATRules returns the existing NAT rules from the zone. +func (f *NFT) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { + c, err := nftConn() + if err != nil { + return nil, err + } + sets := newSetReader(c) + + rules, err = f.listOwnNATRules(c, sets) + if err != nil { + return nil, err + } + foreign, ferr := f.listForeignNATRules(c, sets) + if ferr != nil { + return nil, ferr + } + rules = append(rules, foreign...) + return rules, nil +} + +// ----------------------------------------------------------------------------- +// Table and chain setup +// ----------------------------------------------------------------------------- + +// nftBaseChain describes one of the private table's base chains. +type nftBaseChain struct { + name string + chType nftables.ChainType + hook *nftables.ChainHook + priority *nftables.ChainPriority +} + +// nftFilterBaseChains defines the filter hooks the private table installs. +var nftFilterBaseChains = []nftBaseChain{ + {"input", nftables.ChainTypeFilter, nftables.ChainHookInput, nftables.ChainPriorityFilter}, + {"output", nftables.ChainTypeFilter, nftables.ChainHookOutput, nftables.ChainPriorityFilter}, + {"forward", nftables.ChainTypeFilter, nftables.ChainHookForward, nftables.ChainPriorityFilter}, +} + +// nftNATBaseChains defines the nat hooks, created lazily on first NAT write. +var nftNATBaseChains = []nftBaseChain{ + {"prerouting", nftables.ChainTypeNAT, nftables.ChainHookPrerouting, nftables.ChainPriorityNATDest}, + {"postrouting", nftables.ChainTypeNAT, nftables.ChainHookPostrouting, nftables.ChainPriorityNATSource}, +} + +// ensureTable creates the private table and its filter base chains if they do +// not already exist. Adding an existing table or chain re-asserts it rather than +// failing, so re-running is safe. +// +// The chain definitions deliberately leave the policy unset: re-adding an +// existing base chain re-asserts the named properties, so stating "accept" here +// would revert a default-drop policy a prior SetDefaultPolicy set. A base chain +// created without a policy defaults to accept (the intended initial default), +// and omitting it leaves any existing policy untouched. +func (f *NFT) ensureTable(ctx context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.ensured { + return nil + } + c, err := nftConn() + if err != nil { + return err + } + tbl := c.AddTable(f.tableRef()) + for _, bc := range nftFilterBaseChains { + c.AddChain(&nftables.Chain{ + Name: bc.name, + Table: tbl, + Type: bc.chType, + Hooknum: bc.hook, + Priority: bc.priority, + }) + } + if err := c.Flush(); err != nil { + return fmt.Errorf("failed to set up nftables table %s: %s", f.table, err) + } + f.ensured = true + return nil +} + +// ensureNATChains creates the private table's nat base chains (prerouting for +// destination NAT, postrouting for source NAT) if they do not already exist. It +// is called lazily the first time a NAT rule is written so filter-only use never +// installs nat hooks. +func (f *NFT) ensureNATChains(ctx context.Context) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + f.mu.Lock() + defer f.mu.Unlock() + if f.natEnsured { + return nil + } + c, err := nftConn() + if err != nil { + return err + } + tbl := f.tableRef() + accept := nftables.ChainPolicyAccept + for _, bc := range nftNATBaseChains { + c.AddChain(&nftables.Chain{ + Name: bc.name, + Table: tbl, + Type: bc.chType, + Hooknum: bc.hook, + Priority: bc.priority, + Policy: &accept, + }) + } + if err := c.Flush(); err != nil { + return fmt.Errorf("failed to set up nftables nat chains for %s: %s", f.table, err) + } + f.natEnsured = true + return nil +} + +// ----------------------------------------------------------------------------- +// Placement +// ----------------------------------------------------------------------------- + +// registerSets stages the sets an encoded rule references. Anonymous sets are +// created in the same batch as the rule that looks them up; the named meter set +// outlives any single rule, so it is created only when absent. +func (f *NFT) registerSets(c *nftables.Conn, enc *nftEncoded) error { + for _, as := range enc.anonSets { + if err := c.AddSet(as.set, as.elements); err != nil { + return err + } + } + if enc.meterSet != nil { + _, err := c.GetSetByName(f.tableRef(), enc.meterSet.Name) + switch { + case err == nil: + // The set already counts for this rule; reusing it keeps the counting + // state a re-add is meant to inherit. + case f.isNotExist(err): + if aerr := c.AddSet(enc.meterSet, nil); aerr != nil { + return aerr + } + default: + return err + } + } + return nil +} + +// placeRule stages an encoded rule for addition at the 0-based physical index +// insPos. nftables positions a new rule relative to an existing one's handle, so +// an index inside the chain inserts before the row currently there and an index +// at or past the end appends. A negative index appends too, which is how AddRule +// asks for a plain append. +func (f *NFT) placeRule(c *nftables.Conn, enc *nftEncoded, insPos int, handles []uint64) error { + if err := f.registerSets(c, enc); err != nil { + return err + } + nr := &nftables.Rule{ + Table: f.tableRef(), + Chain: f.chainRef(enc.chain), + Exprs: enc.exprs, + UserData: enc.userData, + } + if insPos >= 0 && insPos < len(handles) { + nr.Position = handles[insPos] + c.InsertRule(nr) + return nil + } + c.AddRule(nr) + return nil +} + +// ruleExists reports whether existing already contains a rule matching r. +// Opaque (nil) rows never match. +func (f *NFT) ruleExists(existing []*Rule, r *Rule) bool { + for _, e := range existing { + if e != nil && e.EqualForDedup(r, true) { + return true + } + } + return false +} + +// natRuleExists is ruleExists for NAT rules. Opaque (nil) rows never match. +func (f *NFT) natRuleExists(existing []*NATRule, r *NATRule) bool { + for _, e := range existing { + if e != nil && e.EqualForDedup(r) { + return true + } + } + return false +} + +// physicalIndex maps a 1-based position over the modeled (non-nil) rows to the +// 0-based physical row index it lands at, keeping opaque rows pinned in place; +// a position past the last modeled row maps to the end of the chain. +func physicalIndex[T any](rows []*T, position int) int { + seen := 0 + for i, e := range rows { + if e == nil { + continue + } + seen++ + if seen == position { + return i + } + } + return len(rows) +} + +// ----------------------------------------------------------------------------- +// Filter rule mutations +// ----------------------------------------------------------------------------- + +// nextSurvivingHandle returns the handle of the first row after index i that a +// removal keeps, or 0 when every later row is being deleted (so a replacement +// belongs at the end of the chain). +func (f *NFT) nextSurvivingHandle(matched []bool, handles []uint64, i int) uint64 { + for j := i + 1; j < len(handles); j++ { + if !matched[j] { + return handles[j] + } + } + return 0 +} + +// removeCovered deletes every chain row the target covers and re-adds each +// merged row's untargeted remainder (see splitMergedRow) in the row's own slot, +// so coverage the caller never named survives in place. The deletions and the +// replacements ride one netlink transaction, so the chain is never briefly +// missing the coverage it keeps. It reports whether any row was deleted. +func (f *NFT) removeCovered(c *nftables.Conn, chain string, rules []*Rule, handles []uint64, r *Rule) (bool, error) { + matched := make([]bool, len(rules)) + splits := make([][]*Rule, len(rules)) + deleted := false + for i, e := range rules { + if e == nil || !e.EqualForRemoval(r, true) { + continue + } + matched[i] = true + deleted = true + // A concrete target that matched a multi-state row would drop coverage + // the caller never asked to remove: an unpinned inet row covers both + // families, and an l4proto-set row both transports. + splits[i] = splitMergedRow(e, r) + } + if !deleted { + return false, nil + } + + tbl := f.tableRef() + ch := f.chainRef(chain) + for i := range rules { + if !matched[i] { + continue + } + if err := c.DelRule(&nftables.Rule{Table: tbl, Chain: ch, Handle: handles[i]}); err != nil { + return false, err + } + } + + // Each remainder takes its row's slot: it is inserted before the first row + // the removal keeps, which is exactly where the deleted row sat. Walking the + // rows in ascending order keeps several remainders in their original order. + for i := range rules { + if !matched[i] { + continue + } + before := f.nextSurvivingHandle(matched, handles, i) + for _, s := range splits[i] { + // A remainder is synthesized here rather than supplied by a caller, so + // it takes the check an entry point would have run. + if verr := f.validateRule(s); verr != nil { + return false, verr + } + enc, merr := f.MarshalRule(s) + if merr != nil { + return false, merr + } + if err := f.registerSets(c, enc); err != nil { + return false, err + } + nr := &nftables.Rule{Table: tbl, Chain: ch, Exprs: enc.exprs, UserData: enc.userData} + if before != 0 { + nr.Position = before + c.InsertRule(nr) + } else { + c.AddRule(nr) + } + } + } + if err := c.Flush(); err != nil { + return false, err + } + + // A deleted per-source connection-limit row leaves its counting set behind; + // drop each removed row's meter set now that its rule is gone. The delete is + // best-effort: one that fails (a surviving row — foreign, or a remainder + // re-added above — still references the set) leaves the set in place, which + // is the correct outcome. + f.sweepMeterSets(rules, matched) + return true, nil +} + +// sweepMeterSets removes the counting sets of the rows a removal deleted. Each +// delete runs in its own transaction so one still-referenced set does not block +// the rest. +func (f *NFT) sweepMeterSets(rules []*Rule, matched []bool) { + for i, e := range rules { + if !matched[i] || e == nil || e.meterSet == "" { + continue + } + c, err := nftConn() + if err != nil { + return + } + c.DelSet(&nftables.Set{Table: f.tableRef(), Name: e.meterSet}) + _ = c.Flush() + } +} + +// insertRule places a rule in its chain, at a 1-based position over the modeled +// rows or, for a negative position, appended. +func (f *NFT) insertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + + // A DirAny rule fans out into an input row plus its role-swapped output row; + // place each in its own chain at the requested position. + if r.Direction == DirAny { + for _, sub := range expandDirections(r) { + if err := f.insertRule(ctx, zoneName, position, sub); err != nil { + return err + } + } + return nil + } + + // A FamilyAny per-source connection limit has no single row (its meter set is + // family-typed): fan out into a v4 row and a v6 row, each with its own set. + if f.perSourceFamilySplit(r) { + for _, sub := range expandFamilies(r) { + if err := f.insertRule(ctx, zoneName, position, sub); err != nil { + return err + } + } + return nil + } + + // Verify the rule is valid for nftables. The fan-outs above run first: + // validateRule asserts a per-source connection limit already carries a + // concrete family. + if err := f.validateRule(r); err != nil { + return err + } + + c, err := nftConn() + if err != nil { + return err + } + + // A family-agnostic set reference is pinned to the set's own family. + r, err = f.resolveSetRefFamily(ctx, c, r) + if err != nil { + return err + } + + enc, err := f.MarshalRule(r) + if err != nil { + return err + } + + // Skip if an equivalent rule already exists. + existing, handles, err := f.listChain(c, newSetReader(c), enc.chain) + if err != nil { + return err + } + if f.ruleExists(existing, r) { + return nil + } + + insPos := -1 + if position >= 1 { + // A position counts the modeled rows, matching GetRules' numbering; + // physicalIndex maps it past any opaque rows to the chain's real index. + insPos = physicalIndex(existing, position) + } + if err := f.placeRule(c, enc, insPos, handles); err != nil { + return err + } + return c.Flush() +} + +// AddRule adds a rule to the zone. +func (f *NFT) AddRule(ctx context.Context, zoneName string, r *Rule) error { + return f.insertRule(ctx, zoneName, -1, r) +} + +// InsertRule inserts rule before the given 1-based position. position <= 0 is +// treated as 1 (prepend); a position larger than the current rule count appends +// the rule. Normalizing here keeps insertRule's -1 sentinel reserved for +// AddRule's plain append. +func (f *NFT) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + if position <= 0 { + position = 1 + } + return f.insertRule(ctx, zoneName, position, r) +} + +// MoveRule moves an existing rule to the given 1-based position. +func (f *NFT) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + if position <= 0 { + position = 1 + } + + // A DirAny rule occupies a slot in both chains; move each half to the requested + // position within its own chain. + if r.Direction == DirAny { + if err := f.ensureTable(ctx); err != nil { + return err + } + for _, sub := range expandDirections(r) { + if err := f.MoveRule(ctx, zoneName, sub, position); err != nil { + return err + } + } + return nil + } + + // A FamilyAny per-source connection limit occupies one row per family; move + // each to the requested position, mirroring the insertRule fan-out. + if f.perSourceFamilySplit(r) { + if err := f.ensureTable(ctx); err != nil { + return err + } + for _, sub := range expandFamilies(r) { + if err := f.MoveRule(ctx, zoneName, sub, position); err != nil { + return err + } + } + return nil + } + + // Verify the rule is valid for nftables; the fan-outs above have already split + // a per-source connection limit per family. + if err := f.validateRule(r); err != nil { + return err + } + + if err := f.ensureTable(ctx); err != nil { + return err + } + + c, err := nftConn() + if err != nil { + return err + } + + // A family-agnostic set reference is pinned to the set's own family so the + // re-add below marshals; the rows it targets are already pinned on read. + r, err = f.resolveSetRefFamily(ctx, c, r) + if err != nil { + return err + } + + chain := f.chainForDirection(r.Direction) + rules, handles, err := f.listChain(c, newSetReader(c), chain) + if err != nil { + return err + } + + // The target's current position is its first matching row's position among + // the modeled rows; moving it there is a no-op. + firstLogical := -1 + logical := 0 + for _, e := range rules { + if e == nil { + continue + } + logical++ + if firstLogical < 0 && e.EqualForRemoval(r, true) { + firstLogical = logical + } + } + if firstLogical < 0 || position == firstLogical { + return nil + } + + // nftables has no native move. Delete every row the target covers — a + // FamilyAny or TCPUDP target spans rows the chain may hold separately, so all + // of them relocate — but a concrete target that matched a merged row must not + // take the untargeted coverage with it: removeCovered re-adds each merged + // row's remainder in its own slot, and only the targeted rule moves. + if _, err := f.removeCovered(c, chain, rules, handles, r); err != nil { + return err + } + + // The rewrite changed the chain's handles, so read it back before placing the + // rule at its new position. Flush emptied the batch, so the same scope takes + // the follow-up placement; the set reader is rebuilt because its cache + // predates the rewrite. + after, afterHandles, err := f.listChain(c, newSetReader(c), chain) + if err != nil { + return err + } + enc, err := f.MarshalRule(r) + if err != nil { + return err + } + if err := f.placeRule(c, enc, physicalIndex(after, position), afterHandles); err != nil { + return err + } + return c.Flush() +} + +// RemoveRule removes a rule from the zone. +func (f *NFT) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + + // A DirAny target removes both its input row and its role-swapped output row, + // each from its own chain. + if r.Direction == DirAny { + for _, sub := range expandDirections(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // Verify the rule is valid for nftables. Only the encoding paths run + // validateRule: a removal target is matched against parsed rows, never + // encoded, and RemoveRule has no per-family fan-out, so a FamilyAny + // per-source connection limit is a legitimate target here. + if err := r.validate(); err != nil { + return err + } + + c, err := nftConn() + if err != nil { + return err + } + chain := f.chainForDirection(r.Direction) + rules, handles, err := f.listChain(c, newSetReader(c), chain) + if err != nil { + return err + } + // Delete every row the target covers, not just the first: a FamilyAny target + // clears both an unpinned row and any family-pinned rows it spans, and a TCPUDP + // target clears both transports. A concrete-family target still removes only its + // own family — see EqualForRemoval; removeCovered re-adds each merged row's + // untargeted remainder in the row's own slot. + _, err = f.removeCovered(c, chain, rules, handles, r) + return err +} + +// ----------------------------------------------------------------------------- +// NAT rule mutations +// ----------------------------------------------------------------------------- + +// removeCoveredNAT is removeCovered for NAT rules: it deletes every chain row +// the target covers and re-adds each dual-family row's untargeted family +// remainder (see splitNATDualRow) in the row's own slot, so family coverage the +// caller never named survives in place. Family is the only axis a NAT rule +// spans, so a concrete-family target never splits a row it matches. +func (f *NFT) removeCoveredNAT(c *nftables.Conn, chain string, rules []*NATRule, handles []uint64, r *NATRule) (bool, error) { + matched := make([]bool, len(rules)) + splits := make([]*NATRule, len(rules)) + deleted := false + for i, e := range rules { + if e == nil || !e.EqualForRemoval(r) { + continue + } + matched[i] = true + deleted = true + // A concrete target that matched a genuine dual-family row (an unpinned + // inet row covering both) would drop the family the caller did not name. + splits[i] = splitNATDualRow(e, r) + } + if !deleted { + return false, nil + } + + tbl := f.tableRef() + ch := f.chainRef(chain) + for i := range rules { + if !matched[i] { + continue + } + if err := c.DelRule(&nftables.Rule{Table: tbl, Chain: ch, Handle: handles[i]}); err != nil { + return false, err + } + } + for i := range rules { + if !matched[i] || splits[i] == nil { + continue + } + // A remainder is synthesized here rather than supplied by a caller, so it + // takes the check an entry point would have run. + if verr := f.validateNAT(splits[i]); verr != nil { + return false, verr + } + enc, merr := f.MarshalNATRule(splits[i]) + if merr != nil { + return false, merr + } + if err := f.registerSets(c, enc); err != nil { + return false, err + } + nr := &nftables.Rule{Table: tbl, Chain: ch, Exprs: enc.exprs, UserData: enc.userData} + if before := f.nextSurvivingHandle(matched, handles, i); before != 0 { + nr.Position = before + c.InsertRule(nr) + } else { + c.AddRule(nr) + } + } + return true, c.Flush() +} + +// addNATRule places a NAT rule in its chain, at a 1-based position over the +// modeled rows or, for a negative position, appended. +func (f *NFT) addNATRule(ctx context.Context, position int, r *NATRule) error { + // Verify the rule is valid for nftables. + if err := f.validateNAT(r); err != nil { + return err + } + + if err := f.ensureNATChains(ctx); err != nil { + return err + } + + c, err := nftConn() + if err != nil { + return err + } + + // A family-agnostic set reference is pinned to the set's own family. + r, err = f.resolveNATSetRefFamily(ctx, c, r) + if err != nil { + return err + } + + enc, err := f.MarshalNATRule(r) + if err != nil { + return err + } + + existing, handles, err := f.listNATChain(c, newSetReader(c), enc.chain) + if err != nil { + return err + } + if f.natRuleExists(existing, r) { + return nil + } + + insPos := -1 + if position >= 1 { + insPos = physicalIndex(existing, position) + } + if err := f.placeRule(c, enc, insPos, handles); err != nil { + return err + } + return c.Flush() +} + +// AddNATRule adds a NAT rule to the zone. +func (f *NFT) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + return f.addNATRule(ctx, -1, r) +} + +// InsertNATRule inserts a NAT rule before the given 1-based position within its +// nat chain. position <= 0 is treated as 1; a position larger than the chain's +// current rule count appends the rule. +func (f *NFT) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + if position <= 0 { + position = 1 + } + return f.addNATRule(ctx, position, r) +} + +// MoveNATRule moves an existing NAT rule to the given 1-based position within +// its nat chain. position <= 0 is treated as 1; a position larger than the +// chain's current rule count moves the rule to the end. +func (f *NFT) MoveNATRule(ctx context.Context, zoneName string, r *NATRule, position int) error { + if position <= 0 { + position = 1 + } + + // Verify the rule is valid for nftables. + if err := f.validateNAT(r); err != nil { + return err + } + + if err := f.ensureNATChains(ctx); err != nil { + return err + } + + c, err := nftConn() + if err != nil { + return err + } + r, err = f.resolveNATSetRefFamily(ctx, c, r) + if err != nil { + return err + } + enc, err := f.MarshalNATRule(r) + if err != nil { + return err + } + + rules, handles, err := f.listNATChain(c, newSetReader(c), enc.chain) + if err != nil { + return err + } + + // The target's current position is its first matched row's position among the + // modeled rows; moving it there is a no-op. + firstLogical := -1 + logical := 0 + for _, e := range rules { + if e == nil { + continue + } + logical++ + if firstLogical < 0 && e.EqualForRemoval(r) { + firstLogical = logical + } + } + if firstLogical < 0 || position == firstLogical { + return nil + } + + // nftables has no native move; see MoveRule for why the covered rows are + // deleted and each dual row's untargeted family re-added in its own slot. + if _, err := f.removeCoveredNAT(c, enc.chain, rules, handles, r); err != nil { + return err + } + + // Flush emptied the batch, so the same scope takes the follow-up placement + // against the handles the rewrite left behind. + after, afterHandles, err := f.listNATChain(c, newSetReader(c), enc.chain) + if err != nil { + return err + } + if err := f.placeRule(c, enc, physicalIndex(after, position), afterHandles); err != nil { + return err + } + return c.Flush() +} + +// RemoveNATRule removes a NAT rule from the zone. +func (f *NFT) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + // Verify the rule is valid for nftables. + if err := r.validate(); err != nil { + return err + } + + if err := f.ensureNATChains(ctx); err != nil { + return err + } + + chain := "prerouting" + if r.Kind.isSource() { + chain = "postrouting" + } + + c, err := nftConn() + if err != nil { + return err + } + rules, handles, err := f.listNATChain(c, newSetReader(c), chain) + if err != nil { + return err + } + // Delete every matching row (see RemoveRule): a FamilyAny NAT target must clear + // both the unpinned row it names and any family-pinned rows it covers, while a + // concrete-family target removes only its own family. A concrete-family target + // that matched a genuine dual-family row re-adds the untargeted family in the + // row's slot, so a bare masquerade/redirect does not silently stop translating + // the other family. + _, err = f.removeCoveredNAT(c, chain, rules, handles, r) + return err +} + +// ----------------------------------------------------------------------------- +// Default policy +// ----------------------------------------------------------------------------- + +// chainPolicies reads the policy of the private table's filter base chains. A +// chain that does not exist yet has no policy to report and is left as +// ActionInvalid. +func (f *NFT) chainPolicies(c *nftables.Conn) (map[string]Action, error) { + out := map[string]Action{} + chains, err := c.ListChainsOfTableFamily(nftables.TableFamilyINet) + if err != nil { + if f.isNotExist(err) { + return out, nil + } + return nil, err + } + for _, ch := range chains { + if ch.Table.Name != f.table || ch.Policy == nil { + continue + } + switch *ch.Policy { + case nftables.ChainPolicyAccept: + out[ch.Name] = Accept + case nftables.ChainPolicyDrop: + out[ch.Name] = Drop + } + } + return out, nil +} + +// GetDefaultPolicy returns the default action applied to packets that match no rule. +func (f *NFT) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + c, err := nftConn() + if err != nil { + return nil, err + } + policies, err := f.chainPolicies(c) + if err != nil { + return nil, err + } + return &DefaultPolicy{ + Input: policies["input"], + Output: policies["output"], + Forward: policies["forward"], + }, nil +} + +// SetDefaultPolicy sets the policy of the named directions. nftables chain +// policies may only be accept or drop; reject is not expressible. Re-adding the +// base chain with a policy is how a policy is changed, so its hook properties +// are restated alongside. +func (f *NFT) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + if policy == nil { + return fmt.Errorf("policy cannot be nil") + } + if err := f.ensureTable(ctx); err != nil { + return err + } + + wanted := map[string]Action{ + "input": policy.Input, + "output": policy.Output, + "forward": policy.Forward, + } + c, err := nftConn() + if err != nil { + return err + } + tbl := f.tableRef() + for _, bc := range nftFilterBaseChains { + action, ok := wanted[bc.name] + if !ok || action == ActionInvalid { + continue + } + var pol nftables.ChainPolicy + switch action { + case Accept: + pol = nftables.ChainPolicyAccept + case Drop: + pol = nftables.ChainPolicyDrop + default: + return fmt.Errorf("nftables chain policy may only be accept or drop") + } + c.AddChain(&nftables.Chain{ + Name: bc.name, + Table: tbl, + Type: bc.chType, + Hooknum: bc.hook, + Priority: bc.priority, + Policy: &pol, + }) + } + return c.Flush() +} + +// ----------------------------------------------------------------------------- +// Address sets +// ----------------------------------------------------------------------------- + +// setKeyType returns the nftables key type a family's addresses are stored as. +func (f *NFT) setKeyType(family Family) (nftables.SetDatatype, error) { + switch family { + case IPv6: + return nftables.TypeIP6Addr, nil + case IPv4, FamilyAny: + // An nftables set carries a single address type, so an unspecified family + // resolves to IPv4. + return nftables.TypeIPAddr, nil + } + return nftables.SetDatatype{}, fmt.Errorf("a set requires a concrete ip family: %w", ErrUnsupportedSet) +} + +// familyForKeyType reverses setKeyType. +func (f *NFT) familyForKeyType(t nftables.SetDatatype) Family { + switch t.Name { + case "ipv6_addr": + return IPv6 + case "ipv4_addr": + return IPv4 + } + return FamilyAny +} + +// addrFromKey decodes a set element key as an address. +func (f *NFT) addrFromKey(key []byte) (netip.Addr, bool) { + return netip.AddrFromSlice(key) +} + +// addressSetEntries reconstructs the entries a set holds. A plain set lists +// discrete addresses; an interval set lists boundary markers, each span an +// inclusive start and an exclusive end, reported back as a CIDR when the span is +// exactly one and as a "lo-hi" range otherwise. +func (f *NFT) addressSetEntries(sc *nftSetContents) []string { + type elem struct { + addr netip.Addr + end bool + } + var elems []elem + for _, e := range sc.elements { + addr, ok := f.addrFromKey(e.Key) + if !ok { + continue + } + elems = append(elems, elem{addr: addr, end: e.IntervalEnd}) + } + sort.Slice(elems, func(i, j int) bool { + if elems[i].addr == elems[j].addr { + return !elems[i].end && elems[j].end + } + return elems[i].addr.Less(elems[j].addr) + }) + + var entries []string + if !sc.set.Interval { + for _, e := range elems { + entries = append(entries, e.addr.String()) + } + return entries + } + open := false + var start netip.Addr + for _, e := range elems { + if !e.end { + start, open = e.addr, true + continue + } + if !open { + continue + } + // The stored end is exclusive; the span runs to the address below it. + last := e.addr.Prev() + if !last.IsValid() || last.Less(start) { + open = false + continue + } + rng := netipx.IPRangeFrom(start, last) + if p, ok := rng.Prefix(); ok { + entries = append(entries, p.String()) + } else { + entries = append(entries, rng.String()) + } + open = false + } + return entries +} + +// setElements renders an entry — an address, a CIDR or a "lo-hi" range — as the +// element(s) a set stores it as. An interval set records the inclusive start and +// an end marker at the exclusive upper bound. +func (f *NFT) setElements(entry string, interval bool) ([]nftables.SetElement, error) { + entry = strings.TrimSpace(entry) + var from, to netip.Addr + switch { + case strings.Contains(entry, "/"): + p, err := netip.ParsePrefix(entry) + if err != nil { + return nil, fmt.Errorf("invalid set entry %q: %s", entry, err) + } + rng := netipx.RangeOfPrefix(p.Masked()) + from, to = rng.From(), rng.To() + case strings.Contains(entry, "-"): + rng, err := netipx.ParseIPRange(entry) + if err != nil { + return nil, fmt.Errorf("invalid set entry %q: %s", entry, err) + } + from, to = rng.From(), rng.To() + default: + addr, err := netip.ParseAddr(entry) + if err != nil { + return nil, fmt.Errorf("invalid set entry %q: %s", entry, err) + } + from, to = addr, addr + } + + if !interval { + if from != to { + return nil, fmt.Errorf("set entry %q spans a range, which requires an interval set", entry) + } + return []nftables.SetElement{{Key: from.AsSlice()}}, nil + } + end := to.Next() + if !end.IsValid() { + return nil, fmt.Errorf("set entry %q reaches the end of the address space", entry) + } + return []nftables.SetElement{ + {Key: from.AsSlice()}, + {Key: end.AsSlice(), IntervalEnd: true}, + }, nil +} + +// getAddressSet reads a single nftables set as an AddressSet, or nil if it does +// not exist. +func (f *NFT) getAddressSet(c *nftables.Conn, name string) (*AddressSet, error) { + set, err := c.GetSetByName(f.tableRef(), name) + if err != nil { + // A missing set is a no-op for the callers that probe with it; any other + // failure must surface rather than reading as "not there", or a Backup + // would silently capture fewer sets than exist. + if f.isNotExist(err) { + return nil, nil + } + return nil, err + } + // A dynamic set is connection-limit counting state, not an address set; + // report it as not-found so no caller manages it as data. + if set.Dynamic { + return nil, nil + } + elems, err := c.GetSetElements(set) + if err != nil { + return nil, err + } + out := &AddressSet{Name: name, Family: f.familyForKeyType(set.KeyType)} + if set.Interval { + out.Type = SetHashNet + } + out.Entries = f.addressSetEntries(&nftSetContents{set: set, elements: elems}) + return out, nil +} + +// GetAddressSets returns the address sets managed by this backend. +func (f *NFT) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + if err := f.ensureTable(ctx); err != nil { + return nil, err + } + c, err := nftConn() + if err != nil { + return nil, err + } + sets, err := c.GetSets(f.tableRef()) + if err != nil { + return nil, err + } + result := make([]*AddressSet, 0, len(sets)) + for _, s := range sets { + // A dynamic set is a per-source connection-limit meter, rule mechanics + // rather than a caller-managed address set; reporting one would let a + // Backup/Restore or a caller's sweep manage counting state as data. An + // anonymous set is a rule's own inline literal, likewise not data. + if s.Dynamic || s.Anonymous { + continue + } + detail, derr := f.getAddressSet(c, s.Name) + if derr != nil { + return nil, derr + } + if detail == nil { + continue + } + result = append(result, detail) + } + return result, nil +} + +// GetAddressSet returns a single address set by name, or an error if it does not exist. +func (f *NFT) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + if err := f.ensureTable(ctx); err != nil { + return nil, err + } + c, err := nftConn() + if err != nil { + return nil, err + } + set, err := f.getAddressSet(c, name) + if err != nil { + return nil, err + } + if set == nil { + return nil, fmt.Errorf("address set %q not found", name) + } + return set, nil +} + +// setMatches reports whether an existing set's definition matches a requested +// family/type, so AddAddressSet can tell a harmless re-add of an identical set +// apart from a genuine type/family conflict, which must surface as an error +// rather than being silently swallowed. A nil existing set never matches. +func (f *NFT) setMatches(existing *AddressSet, wantFamily Family, wantType SetType) bool { + if existing == nil { + return false + } + if wantFamily == FamilyAny { + wantFamily = IPv4 + } + return existing.Family == wantFamily && existing.Type == wantType +} + +// AddAddressSet creates an address set. Adding a set that already exists (by name) +// with the same definition is a no-op. +func (f *NFT) AddAddressSet(ctx context.Context, set *AddressSet) error { + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + if err := f.ensureTable(ctx); err != nil { + return err + } + keyType, err := f.setKeyType(set.Family) + if err != nil { + return err + } + + c, err := nftConn() + if err != nil { + return err + } + // An existing set is only acceptable when it is the same set; a differing + // definition is a genuine conflict. + if existing, gerr := f.getAddressSet(c, set.Name); gerr != nil { + return gerr + } else if existing != nil { + if !f.setMatches(existing, set.Family, set.Type) { + return fmt.Errorf("address set %q already exists with a different definition", set.Name) + } + return nil + } + + interval := set.Type == SetHashNet + var elems []nftables.SetElement + for _, e := range set.Entries { + els, eerr := f.setElements(e, interval) + if eerr != nil { + return eerr + } + elems = append(elems, els...) + } + if err := c.AddSet(&nftables.Set{ + Table: f.tableRef(), + Name: set.Name, + KeyType: keyType, + Interval: interval, + }, elems); err != nil { + return err + } + return c.Flush() +} + +// RemoveAddressSet removes an address set by name. +func (f *NFT) RemoveAddressSet(ctx context.Context, name string) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + c, err := nftConn() + if err != nil { + return err + } + c.DelSet(&nftables.Set{Table: f.tableRef(), Name: name}) + if err := c.Flush(); err != nil { + if f.isNotExist(err) { + return nil + } + return err + } + return nil +} + +// setEntryElements resolves the set an entry is being written to and renders the +// entry as its element(s), so an interval set's boundary markers match the set's +// actual definition rather than a guess. +func (f *NFT) setEntryElements(c *nftables.Conn, name, entry string) (*nftables.Set, []nftables.SetElement, error) { + set, err := c.GetSetByName(f.tableRef(), name) + if err != nil { + if f.isNotExist(err) { + return nil, nil, fmt.Errorf("address set %q not found", name) + } + return nil, nil, err + } + elems, err := f.setElements(entry, set.Interval) + if err != nil { + return nil, nil, err + } + return set, elems, nil +} + +// AddAddressSetEntry adds an entry to the named set. +func (f *NFT) AddAddressSetEntry(ctx context.Context, name, entry string) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + c, err := nftConn() + if err != nil { + return err + } + set, elems, err := f.setEntryElements(c, name, entry) + if err != nil { + return err + } + if err := c.SetAddElements(set, elems); err != nil { + return err + } + return c.Flush() +} + +// RemoveAddressSetEntry removes an entry from the named set. +func (f *NFT) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + c, err := nftConn() + if err != nil { + return err + } + set, elems, err := f.setEntryElements(c, name, entry) + if err != nil { + return err + } + if err := c.SetDeleteElements(set, elems); err != nil { + return err + } + return c.Flush() +} + +// setRefFamily resolves the single family of the named address set(s) a rule +// references through the shared resolver core, reading each set from this +// backend's own store — nft named sets are not kernel ipsets, so the ipset +// resolver cannot see them. A named set is family-typed, so a family-agnostic +// set-referencing rule is pinned to the set's own family rather than rejected: +// its rows could never match the other family anyway. +func (f *NFT) setRefFamily(c *nftables.Conn, source, destination string) (Family, error) { + return setRefFamilyFrom(func(name string) (Family, bool, error) { + set, err := f.getAddressSet(c, name) + if err != nil { + return FamilyAny, false, err + } + if set == nil { + return FamilyAny, false, nil + } + return set.Family, true, nil + }, source, destination) +} + +// resolveSetRefFamily returns r pinned to its referenced set's family when the +// rule is family-agnostic and names a set; every other rule passes through +// unchanged. Callers resolve before marshalling, so MarshalRule never sees an +// unpinned set reference. +func (f *NFT) resolveSetRefFamily(ctx context.Context, c *nftables.Conn, r *Rule) (*Rule, error) { + return resolveSetRefRule(r, func(source, destination string) (Family, error) { + return f.setRefFamily(c, source, destination) + }) +} + +// resolveNATSetRefFamily is resolveSetRefFamily for NAT rules. +func (f *NFT) resolveNATSetRefFamily(ctx context.Context, c *nftables.Conn, r *NATRule) (*NATRule, error) { + return resolveSetRefNAT(r, func(source, destination string) (Family, error) { + return f.setRefFamily(c, source, destination) + }) +} + +// ----------------------------------------------------------------------------- +// Backup and lifecycle +// ----------------------------------------------------------------------------- + +// Backup captures the filter and NAT rules in this backend's private table. +func (f *NFT) Backup(ctx context.Context, zoneName string) (*Backup, error) { + c, err := nftConn() + if err != nil { + return nil, err + } + sets := newSetReader(c) + + // Read the private table directly rather than GetRules: Restore refills only + // this table, so the backup must not pull in rules from foreign tables (they + // would be re-added into the wrong table on Restore). + rules, err := f.listOwnRules(c, sets) + if err != nil { + return nil, err + } + natRules, err := f.listOwnNATRules(c, sets) + if err != nil { + return nil, err + } + backup := &Backup{Rules: rules, NATRules: natRules} + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *NFT) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + if err := f.ensureTable(ctx); err != nil { + return err + } + if err := f.ensureNATChains(ctx); err != nil { + return err + } + + // Clear the modeled rows by handle rather than flushing the table: an + // unmodeled row (a foreign construct hand-added into the private table) is + // invisible to Backup, so a flush would destroy state the snapshot cannot + // reproduce. + if err := f.clearModeledRows(ctx); err != nil { + return err + } + + // Recreate the sets on a clean slate before the rules that reference them. The + // clear above removed every modeled rule, so no modeled rule holds a set + // reference and each set can be removed and rebuilt; the clean rebuild is + // required because AddAddressSet is a no-op on an existing set and would not + // otherwise restore a flushed set's elements. An unmodeled row that still + // references a set surfaces here as a delete-set error rather than being + // silently destroyed. + if err := restoreBackupSets(ctx, f, backup, true); err != nil { + return err + } + + for _, r := range backup.Rules { + if err := f.AddRule(ctx, zoneName, r); err != nil { + return err + } + } + for _, r := range backup.NATRules { + if err := f.AddNATRule(ctx, zoneName, r); err != nil { + return err + } + } + return applyBackupPolicy(ctx, f, zoneName, backup) +} + +// clearModeledRows deletes every modeled rule row from the private table's +// filter and nat chains by handle, in one netlink transaction, leaving unmodeled +// (opaque) rows in place. Chain hooks and policies are untouched. The cleared +// rows' per-source meter sets are swept afterwards, best-effort, so a restore +// does not strand counting state; a rule the restore re-adds auto-creates its +// set again. +func (f *NFT) clearModeledRows(ctx context.Context) error { + c, err := nftConn() + if err != nil { + return err + } + sets := newSetReader(c) + tbl := f.tableRef() + staged := false + + var staleRules []*Rule + var staleMatched []bool + for _, chain := range nftFilterChains { + rules, handles, lerr := f.listChain(c, sets, chain) + if lerr != nil { + return lerr + } + ch := f.chainRef(chain) + for i, e := range rules { + if e == nil { + continue + } + if derr := c.DelRule(&nftables.Rule{Table: tbl, Chain: ch, Handle: handles[i]}); derr != nil { + return derr + } + staged = true + staleRules = append(staleRules, e) + staleMatched = append(staleMatched, true) + } + } + for _, chain := range nftNATChains { + rules, handles, lerr := f.listNATChain(c, sets, chain) + if lerr != nil { + return lerr + } + ch := f.chainRef(chain) + for i, e := range rules { + if e == nil { + continue + } + if derr := c.DelRule(&nftables.Rule{Table: tbl, Chain: ch, Handle: handles[i]}); derr != nil { + return derr + } + staged = true + } + } + if !staged { + return nil + } + if err := c.Flush(); err != nil { + return err + } + f.sweepMeterSets(staleRules, staleMatched) + return nil +} + +// Reload is a no-op; nftables applies changes immediately, so there is nothing to reload. +func (f *NFT) Reload(ctx context.Context) error { + return nil +} + +// Close closes the connection to the manager. +func (f *NFT) Close(ctx context.Context) error { + return nil +} diff --git a/nft_linux_test.go b/nft_linux_test.go new file mode 100644 index 0000000..f2b2030 --- /dev/null +++ b/nft_linux_test.go @@ -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) + } +} diff --git a/pf.go b/pf.go new file mode 100644 index 0000000..ff97482 --- /dev/null +++ b/pf.go @@ -0,0 +1,2300 @@ +//go:build darwin || freebsd + +package firewall + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +const ( + // PFDefaultAnchor is the pf anchor name used when no rule prefix is supplied. + PFDefaultAnchor = "go_firewall" + // PFConf is the main pf configuration file. + PFConf = "/etc/pf.conf" +) + +// PF manages firewall rules through OpenBSD's Packet Filter (pf), used on both +// macOS and FreeBSD. To avoid disturbing rules owned by the base system, this +// backend keeps every rule it creates inside a private anchor (named after the +// rule prefix). The anchor is referenced from the main pf.conf so its rules are +// evaluated, but the rules themselves are loaded and read through pfctl scoped +// to the anchor. +type PF struct { + // anchor is the pf anchor this backend owns. + anchor string + // ensured records whether the anchor reference has been added to pf.conf + // this session so we only check/patch it once. The flag is session state: if + // something else rewrites pf.conf without our reference after we have latched + // it, this process keeps loading rules into an anchor nothing evaluates until + // it restarts. + ensured bool + // natEnsured records the same for the nat/rdr anchor references, added + // lazily only when a NAT rule is first written, and carries the same + // session-state caveat. + natEnsured bool +} + +// pfICMPTypeNames maps the icmp-type names pfctl prints (which differ from the +// hyphenated aliases in icmpNameToNum, e.g. `echoreq` vs `echo-request`) to their +// numeric type, so an icmp-type match round-trips whether pfctl emits a number or +// a name. +var pfICMPTypeNames = map[string]uint8{ + "echorep": 0, "unreach": 3, "squench": 4, "redir": 5, "althost": 6, + "echoreq": 8, "routeradv": 9, "routersol": 10, "timex": 11, + "paramprob": 12, "timereq": 13, "timerep": 14, "inforeq": 15, + "inforep": 16, "maskreq": 17, "maskrep": 18, "trace": 30, + "dataconv": 31, "mobredir": 32, "ipv6-where": 33, "ipv6-here": 34, + "mobregreq": 35, "mobregrep": 36, "skip": 39, "photuris": 40, +} + +// pfICMP6TypeNames maps the icmp6-type names pfctl prints to their numeric +// ICMPv6 type. Several spellings collide with the ICMPv4 names in +// pfICMPTypeNames but mean a different number (e.g. `unreach` is 3 for ICMPv4 +// but 1 for ICMPv6, `echoreq` is 8 vs 128), so an icmp6-type match must be +// resolved through this table rather than the ICMPv4 one. +var pfICMP6TypeNames = map[string]uint8{ + "unreach": 1, "toobig": 2, "timex": 3, "paramprob": 4, + "echoreq": 128, "echorep": 129, + "groupqry": 130, "listqry": 130, "grouprep": 131, "listenrep": 131, + "groupterm": 132, "listendone": 132, + "routersol": 133, "routeradv": 134, "neighbrsol": 135, "neighbradv": 136, + "redir": 137, "routrrenum": 138, + "fqdnreq": 139, "niqry": 139, "fqdnrep": 140, "nirep": 140, +} + +// sanitizePFName reduces an arbitrary prefix to a safe pf anchor name, falling +// back to the default when nothing usable remains. +func sanitizePFName(prefix string) string { + var b strings.Builder + for _, r := range prefix { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': + b.WriteRune(r) + case r == ' ' || r == '.': + b.WriteRune('_') + } + } + name := strings.Trim(b.String(), "_-") + if name == "" { + return PFDefaultAnchor + } + return name +} + +// NewPF constructs a PF backend scoped to an anchor derived from rulePrefix, +// verifying pfctl is available and pf is enabled. +func NewPF(ctx context.Context, rulePrefix string) (*PF, error) { + pf := &PF{anchor: sanitizePFName(rulePrefix)} + + // Confirm pfctl is available and pf is enabled; otherwise our rules would + // never take effect and we should let another manager (or none) be chosen. + out, err := runCommand(ctx, "pfctl", "-s", "info") + if err != nil { + return nil, fmt.Errorf("pfctl is not available: %s", err) + } + enabled := false + for _, line := range out { + if strings.HasPrefix(strings.TrimSpace(line), "Status: Enabled") { + enabled = true + break + } + } + if !enabled { + return nil, fmt.Errorf("pf is not enabled on this server") + } + + return pf, nil +} + +// Type returns the backend type string for pf. +func (f *PF) Type() string { + return PFType +} + +// Capabilities returns the set of features the pf backend can express. +func (f *PF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + IPv6: true, + PortPair: true, + // pf keeps state on a pass rule automatically but exposes no equivalent of + // the conntrack-state match the model carries, so a rule naming a state is + // rejected rather than silently losing it (see validateRule). + ConnState: false, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: true, + // pf's block/pass policy is a property of the main ruleset (a leading + // `block all`), not of the anchor this backend owns, so there is no default + // policy it can report or set without rewriting rules it does not manage. + DefaultPolicy: false, + RuleCounters: true, + AddressSets: true, + Comments: true, + Negation: true, + RejectAction: true, + FamilyWithoutAddress: true, + } +} + +// GetZone reports no zone; pf has no interface-to-zone mapping in the model we expose. +func (f *PF) GetZone(ctx context.Context, iface string) (zoneName string, err error) { + return "", nil +} + +// --- pf.conf anchor references and anchor loading ----------------------------- + +// readFileLines reads a file and returns its lines with trailing newlines +// stripped. +func (f *PF) readFileLines(path string) ([]string, error) { + fd, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = fd.Close() }() + + var lines []string + scanner := bufio.NewScanner(fd) + // pf.conf can carry a very long single line (e.g. a large table macro); raise + // the token cap well above the default 64 KB so such a line is not rejected. + scanner.Buffer(make([]byte, 0, 64*1024), 64*1024*1024) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} + +// writeFileLines atomically replaces path with the provided lines by writing to +// a uniquely-named temp file in the same directory and renaming it into place. +// The original file's mode and ownership are preserved (defaulting to 0600 for a +// new file) so a rewrite never loosens restrictive permissions. +func (f *PF) writeFileLines(path string, lines []string) error { + af, err := newAtomicFile(path, 0600) + if err != nil { + return err + } + defer af.Abort() + w := bufio.NewWriter(af) + for _, line := range lines { + _, _ = fmt.Fprintln(w, line) + } + if err := w.Flush(); err != nil { + return err + } + return af.Commit() +} + +// ensureAnchor makes sure pf.conf references our anchor so that rules loaded +// into it are evaluated. If the reference is missing it is appended and pf.conf +// is reloaded. Filter anchors are evaluated in place, so appending keeps our +// rules after the base ruleset. +func (f *PF) ensureAnchor(ctx context.Context) error { + if f.ensured { + return nil + } + + data, err := f.readFileLines(PFConf) + if err != nil { + return err + } + + anchorRef := fmt.Sprintf(`anchor "%s"`, f.anchor) + for _, line := range data { + if strings.TrimSpace(line) == anchorRef { + f.ensured = true + return nil + } + } + + // Append the anchor reference and reload the main ruleset. + data = append(data, anchorRef) + if err := f.writeFileLines(PFConf, data); err != nil { + return err + } + if _, err := runCommand(ctx, "pfctl", "-f", PFConf); err != nil { + return fmt.Errorf("failed to reload pf.conf after adding anchor: %s", err) + } + f.ensured = true + return nil +} + +// pfFilterKeywords are the tokens that begin a pf filtering-section statement. +// Translation anchors (nat/rdr) must be declared before the first of these, so +// ensureNATAnchors inserts them at that boundary. pf.conf sections are strictly +// ordered options → normalization → queueing → translation → filtering, so the +// queueing keywords (altq/queue) are deliberately NOT included: they precede the +// translation section, and treating one as the boundary would splice the nat/rdr +// anchors ahead of the queueing statements, which pfctl -f rejects. +var pfFilterKeywords = map[string]bool{ + "pass": true, "block": true, "match": true, "anchor": true, + "antispoof": true, +} + +// translationBoundary returns the index of the first filtering statement in a +// pf.conf, which is where the nat/rdr translation anchors must be inserted (see +// pfFilterKeywords for why queueing keywords are excluded). When there is no +// filtering statement the boundary is the end of the file, so anchors are appended. +func (f *PF) translationBoundary(data []string) int { + for i, line := range data { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) > 0 && pfFilterKeywords[fields[0]] { + return i + } + } + return len(data) +} + +// ensureNATAnchors makes sure pf.conf carries the nat-anchor and rdr-anchor +// references for our anchor, inserting any missing ones at the translation +// boundary (see translationBoundary), and ensures the filter anchor first via +// ensureAnchor. +func (f *PF) ensureNATAnchors(ctx context.Context) error { + if f.natEnsured { + return nil + } + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + data, err := f.readFileLines(PFConf) + if err != nil { + return err + } + + natRef := fmt.Sprintf(`nat-anchor "%s"`, f.anchor) + rdrRef := fmt.Sprintf(`rdr-anchor "%s"`, f.anchor) + haveNat, haveRdr := false, false + for _, line := range data { + trimmed := strings.TrimSpace(line) + if trimmed == natRef { + haveNat = true + } + if trimmed == rdrRef { + haveRdr = true + } + } + insertAt := f.translationBoundary(data) + if haveNat && haveRdr { + f.natEnsured = true + return nil + } + + var add []string + if !haveRdr { + add = append(add, rdrRef) + } + if !haveNat { + add = append(add, natRef) + } + updated := make([]string, 0, len(data)+len(add)) + updated = append(updated, data[:insertAt]...) + updated = append(updated, add...) + updated = append(updated, data[insertAt:]...) + + if err := f.writeFileLines(PFConf, updated); err != nil { + return err + } + if _, err := runCommand(ctx, "pfctl", "-f", PFConf); err != nil { + return fmt.Errorf("failed to reload pf.conf after adding nat anchors: %s", err) + } + f.natEnsured = true + return nil +} + +// loadAnchor replaces the anchor's ruleset with the provided rules. pf requires +// nat/rdr (translation) rules to precede filter rules in the ruleset, so they +// are written first. An empty combined set flushes the anchor. +func (f *PF) loadAnchor(ctx context.Context, natLines, filterLines []string) error { + all := make([]string, 0, len(natLines)+len(filterLines)) + all = append(all, natLines...) + all = append(all, filterLines...) + stdin := strings.Join(all, "\n") + if stdin != "" { + stdin += "\n" + } + _, err := runCommandStdin(ctx, stdin, "pfctl", "-a", f.anchor, "-f", "-") + return err +} + +// --- filter rule parsing ----------------------------------------------------- +// +// The address, port and table token helpers here are shared with the NAT parser. + +// parseAddr reads an address operand starting at index i, honoring an optional +// leading '!' negation (pf allows `! host` or `!host`). It returns the address, +// the negation prefix to prepend ("" or "!"), and the index of the last token +// consumed. +func (f *PF) parseAddr(tokens []string, i int) (val string, neg string, next int, err error) { + if i >= len(tokens) { + return "", "", 0, fmt.Errorf("missing address value") + } + tok := tokens[i] + if tok == "!" { + neg = "!" + i++ + if i >= len(tokens) { + return "", "", 0, fmt.Errorf("missing address value") + } + tok = tokens[i] + } else if strings.HasPrefix(tok, "!") { + neg = "!" + // Strip the negation from a local copy; the caller's slice must not be + // mutated in place. + tok = strings.TrimPrefix(tok, "!") + } + return tok, neg, i, nil +} + +// parseICMPType resolves a pf icmp-type token: a number, or a name known to +// the ICMP name tables. When v6 is true the token is an icmp6-type and is +// resolved through the ICMPv6 tables (standard names and pfctl's own spellings); +// otherwise the ICMPv4 tables are used. Numbers parse the same in either family. +func (f *PF) parseICMPType(tok string, v6 bool) (uint8, bool) { + if n, ok := parseICMPTypeFamily(tok, v6); ok { + return n, true + } + if v6 { + n, ok := pfICMP6TypeNames[tok] + return n, ok + } + n, ok := pfICMPTypeNames[tok] + return n, ok +} + +// lookupPort resolves a pf port token to its number. pfctl prints a well-known +// port by its /etc/services name (22 -> ssh, 80 -> http, ...), so a non-numeric +// token is looked up as a service name. pf does not record the protocol alongside +// the name, so both tcp and udp are tried. +func (f *PF) lookupPort(tok string) (uint16, error) { + tok = strings.TrimSpace(tok) + if n, err := strconv.ParseUint(tok, 10, 16); err == nil { + return uint16(n), nil + } + for _, netw := range []string{"tcp", "udp"} { + if p, err := net.LookupPort(netw, tok); err == nil { + return uint16(p), nil + } + } + return 0, fmt.Errorf("invalid port %q", tok) +} + +// parsePortRange parses a pf port token that may be a service name, a number, or +// a "lo:hi" range with either endpoint named. +func (f *PF) parsePortRange(tok string) (PortRange, error) { + lo, hi, isRange := strings.Cut(tok, ":") + start, err := f.lookupPort(lo) + if err != nil { + return PortRange{}, err + } + pr := PortRange{Start: start, End: start} + if isRange { + end, err := f.lookupPort(hi) + if err != nil { + return PortRange{}, err + } + pr.End = end + } + return pr, nil +} + +// parsePorts reads a pf port operand starting at index i: a single value, a +// range, or a `{ ... }` list. It returns the parsed specs and the index of the +// last token consumed. +func (f *PF) parsePorts(tokens []string, i int) (specs []PortRange, next int, err error) { + if i >= len(tokens) { + return nil, 0, fmt.Errorf("missing port value") + } + if tokens[i] == "{" { + for i++; i < len(tokens); i++ { + if tokens[i] == "}" { + return specs, i, nil + } + m := strings.Trim(tokens[i], ",") + if m == "" { + continue + } + pr, perr := f.parsePortRange(m) + if perr != nil { + return nil, 0, perr + } + specs = append(specs, pr) + } + return nil, 0, fmt.Errorf("unterminated port set") + } + pr, perr := f.parsePortRange(tokens[i]) + if perr != nil { + return nil, 0, perr + } + return []PortRange{pr}, i, nil +} + +// rateUnitFromSeconds maps a pf rate window in seconds back to a RateUnit, +// falling back to PerSecond for a window that matches no named unit. +func (f *PF) rateUnitFromSeconds(s int) RateUnit { + switch s { + case 60: + return PerMinute + case 3600: + return PerHour + case 86400: + return PerDay + } + return PerSecond +} + +// parseStateOpts parses a pf state-option group `( ... )` starting at +// tokens[i] (strings.Fields has split it on spaces, so the members are +// reassembled) and records any rate/connection limits on r. It returns the +// index of the token that closed the group. +func (f *PF) parseStateOpts(tokens []string, i int, r *Rule) (int, error) { + var b strings.Builder + for ; i < len(tokens); i++ { + if b.Len() > 0 { + b.WriteByte(' ') + } + b.WriteString(tokens[i]) + if strings.HasSuffix(tokens[i], ")") { + break + } + } + group := strings.TrimSpace(b.String()) + group = strings.TrimSuffix(strings.TrimPrefix(group, "("), ")") + for _, opt := range strings.Split(group, ",") { + fields := strings.Fields(strings.TrimSpace(opt)) + if len(fields) < 2 { + continue + } + switch fields[0] { + case "max-src-conn": + n, err := strconv.ParseUint(fields[1], 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid max-src-conn %q", fields[1]) + } + r.ConnLimit = &ConnLimit{Count: uint(n), PerSource: true} + case "max-src-conn-rate": + cnt, secs, ok := strings.Cut(fields[1], "/") + if !ok { + return 0, fmt.Errorf("invalid max-src-conn-rate %q", fields[1]) + } + n, err := strconv.ParseUint(cnt, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid rate %q", fields[1]) + } + s, err := strconv.Atoi(secs) + if err != nil { + return 0, fmt.Errorf("invalid rate window %q", fields[1]) + } + r.RateLimit = &RateLimit{Rate: uint(n), Unit: f.rateUnitFromSeconds(s)} + } + } + return i, nil +} + +// stripTable removes the angle brackets pf prints around a table reference, +// yielding the bare set name stored in Source/Destination. +func (f *PF) stripTable(v string) string { + if strings.HasPrefix(v, "<") && strings.HasSuffix(v, ">") { + return v[1 : len(v)-1] + } + return v +} + +// UnmarshalRule decodes a single pf rule line as produced by `pfctl -sr`. pfctl +// normalizes rules (e.g. `port = 23`, trailing `flags S/SA keep state`), so the +// parser is tolerant of the extra tokens it emits. +func (f *PF) UnmarshalRule(line string) (*Rule, error) { + r := new(Rule) + tokens := strings.Fields(line) + if len(tokens) == 0 { + return nil, fmt.Errorf("empty rule") + } + + i := 0 + + // Action. + switch tokens[i] { + case "pass": + r.Action = Accept + i++ + case "block": + i++ + // Optional block variant: drop / return / return-* . + if i < len(tokens) { + switch { + case tokens[i] == "drop": + r.Action = Drop + i++ + case tokens[i] == "return" || strings.HasPrefix(tokens[i], "return-"): + r.Action = Reject + i++ + default: + // A bare `block` defaults to drop in pf. + r.Action = Drop + } + } else { + r.Action = Drop + } + default: + return nil, fmt.Errorf("unsupported action: %s", tokens[i]) + } + + // Direction. + if i >= len(tokens) { + return nil, fmt.Errorf("missing direction") + } + switch tokens[i] { + case "in": + r.Direction = DirInput + i++ + case "out": + r.Direction = DirOutput + i++ + default: + return nil, fmt.Errorf("unsupported direction: %s", tokens[i]) + } + + for ; i < len(tokens); i++ { + switch tokens[i] { + case "quick", "all", "flags": + // Tokens with no bearing on our rule model. `flags S/SA` trails + // stateful pass rules; skip the qualifier that follows flags. + if tokens[i] == "flags" && i+1 < len(tokens) { + i++ + } + case "log": + r.Log = true + // pf may print a parenthesized option group (e.g. `log (all)`); + // skip it. + if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "(") { + i++ + for i < len(tokens) && !strings.HasSuffix(tokens[i], ")") { + i++ + } + } + case "keep", "modulate", "synproxy": + // State tracking: `keep state [(opts)]`. Consume the `state` keyword + // and parse any parenthesized options for rate/connection limits. + if i+1 < len(tokens) && tokens[i+1] == "state" { + i++ + } + if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "(") { + next, perr := f.parseStateOpts(tokens, i+1, r) + if perr != nil { + return nil, perr + } + i = next + } + case "label": + // A user comment, carried as a pf label emitted last as + // `label ""`. Recover it from the original line rather than the + // whitespace-collapsed tokens so a run of spaces inside the label is not + // folded, slicing from the keyword's quote to end of line and reversing the + // marshal-time strconv.Quote with strconv.Unquote (falling back to a plain + // trim if the token is not a well-formed quoted string). + var joined string + if idx := strings.Index(line, `label "`); idx >= 0 { + joined = line[idx+len("label "):] + } else { + joined = strings.Join(tokens[i+1:], " ") + } + if unq, uerr := strconv.Unquote(joined); uerr == nil { + r.Comment = unq + } else { + r.Comment = trimQuotes(joined) + } + i = len(tokens) + case "state": + // A bare state keyword with no preceding `keep`; nothing to record. + case "on": + // Interface binding, tied to the rule direction. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing interface value") + } + if r.IsOutput() { + r.OutInterface = tokens[i] + } else { + r.InInterface = tokens[i] + } + case "inet": + r.Family = IPv4 + case "inet6": + r.Family = IPv6 + case "proto": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing protocol value") + } + r.Proto = GetProtocol(tokens[i]) + if r.Proto == ProtocolAny { + return nil, fmt.Errorf("unsupported protocol: %s", tokens[i]) + } + case "icmp-type", "icmp6-type": + v6 := tokens[i] == "icmp6-type" + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing icmp type value") + } + n, ok := f.parseICMPType(tokens[i], v6) + if !ok { + return nil, fmt.Errorf("invalid icmp type %q", tokens[i]) + } + r.ICMPType = Ptr(n) + case "from": + val, neg, next, err := f.parseAddr(tokens, i+1) + if err != nil { + return nil, err + } + i = next + if val != "any" { + r.Source = neg + f.stripTable(val) + } + // A source port may follow: `from any port 80` (pfctl normalizes it to + // `from any port = 80`, so skip the operator as the destination case does). + if i+1 < len(tokens) && tokens[i+1] == "port" { + i += 2 + if i >= len(tokens) { + return nil, fmt.Errorf("missing source port value") + } + if tokens[i] == "=" { + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing source port value") + } + } + specs, next, perr := f.parsePorts(tokens, i) + if perr != nil { + return nil, perr + } + i = next + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.SourcePort = specs[0].Start + } else { + r.SourcePorts = specs + } + } + case "to": + val, neg, next, err := f.parseAddr(tokens, i+1) + if err != nil { + return nil, err + } + i = next + if val != "any" { + r.Destination = neg + f.stripTable(val) + } + case "port": + // May be `port 23`, normalized `port = 23`, a range `port 1000:2000` + // or a list `port { 80 443 }`. This appears after `to any` and refers + // to the destination port. + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing port value") + } + if tokens[i] == "=" { + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing port value") + } + } + specs, next, err := f.parsePorts(tokens, i) + if err != nil { + return nil, err + } + i = next + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.Port = specs[0].Start + } else { + r.Ports = specs + } + default: + return nil, fmt.Errorf("unsupported token: %s", tokens[i]) + } + } + + // Infer the family from an address when pf did not print one. + if r.Family == FamilyAny { + addr := r.Source + if addr == "" { + addr = r.Destination + } + addr = strings.TrimPrefix(addr, "!") + if addr != "" { + ip, _, err := net.ParseCIDR(addr) + if err != nil { + ip = net.ParseIP(addr) + } + if ip != nil { + if ip.To4() == nil { + r.Family = IPv6 + } else { + r.Family = IPv4 + } + } + } + } + + if r.Action == ActionInvalid { + return nil, fmt.Errorf("no valid action was provided") + } + return r, nil +} + +// --- filter anchor reading ---------------------------------------------------- + +// parseRuleCounters extracts the Packets and Bytes counts from a pfctl -vsr +// statistics continuation line, e.g. +// "[ Evaluations: 5 Packets: 10 Bytes: 600 States: 0 ]". It returns ok=false +// for a continuation line that carries no counters, so a non-statistics line is +// ignored rather than mistaken for a zeroed counter. +func (f *PF) parseRuleCounters(line string) (packets, bytes uint64, ok bool) { + fields := strings.Fields(strings.Trim(line, "[] ")) + var haveP, haveB bool + for i := 0; i+1 < len(fields); i++ { + switch fields[i] { + case "Packets:": + if v, err := strconv.ParseUint(fields[i+1], 10, 64); err == nil { + packets, haveP = v, true + } + case "Bytes:": + if v, err := strconv.ParseUint(fields[i+1], 10, 64); err == nil { + bytes, haveB = v, true + } + } + } + return packets, bytes, haveP && haveB +} + +// parseAnchorRules decodes the lines of a `pfctl -a -vsr` listing into +// rules and their raw rule text. A rule spans one rule line plus one or more +// indented `[ ... ]` continuation lines; the `[ ... Packets: N Bytes: N ... ]` +// line carries the rule's counters, which are attached to the rule it follows. +func (f *PF) parseAnchorRules(out []string) (rules []*Rule, raw []string) { + for _, line := range out { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // A verbose listing prefixes each rule with its "@N" ruleset index (pfctl + // prints it under -vv, and some pf versions under -v). It is not part of the + // rule and is invalid in a rules file, so strip it before parsing and before + // recording the raw line — loadAnchor feeds raw straight back to `pfctl -f`, + // which would reject a stray "@N". Only strip when the token after '@' is a + // number so a genuine rule never loses content. + if strings.HasPrefix(line, "@") { + if tok, rest, ok := strings.Cut(line, " "); ok { + if _, perr := strconv.ParseUint(tok[1:], 10, 64); perr == nil { + line = strings.TrimSpace(rest) + } + } + } + // A continuation line annotates the rule just parsed rather than starting a + // new one; pull its Packets/Bytes into that rule when present. The verbose + // listing prints counters under every rule, including an unmodeled one held + // as an opaque nil row, whose counters have nowhere to go. + if strings.HasPrefix(line, "[") { + if len(rules) > 0 && rules[len(rules)-1] != nil { + if p, b, ok := f.parseRuleCounters(line); ok { + rules[len(rules)-1].Packets = p + rules[len(rules)-1].Bytes = b + } + } + continue + } + rule, perr := f.UnmarshalRule(line) + if perr != nil { + // A line we cannot model is preserved as an opaque row: a nil rule with + // its raw text kept, so a read-modify-write rewrite of our anchor does + // not silently drop a foreign rule loaded into it. The nil keeps rules + // 1:1 with raw so the physical-row edits never misalign; GetRules, Backup + // and the position math skip the nil entries. + rules = append(rules, nil) + raw = append(raw, line) + continue + } + // Rules loaded in this backend's own anchor: membership in the library's + // private anchor is what sets HasPrefix, so record the anchor and flag it + // as carrying the prefix. + rule.table = f.anchor + rule.HasPrefix = true + rules = append(rules, rule) + raw = append(raw, line) + } + return rules, raw +} + +// anchorRules returns the filter rules currently loaded in our anchor. +func (f *PF) anchorRules(ctx context.Context) (rules []*Rule, raw []string, err error) { + // Read with -vsr so pfctl prints each rule's per-rule counters on a following + // `[ Evaluations: N Packets: N Bytes: N States: N ]` continuation line, + // which parseAnchorRules attaches to the preceding rule (RuleCounters). + out, err := runCommand(ctx, "pfctl", "-a", f.anchor, "-vsr") + if err != nil { + // Propagate a genuine pfctl failure rather than reporting an empty anchor: a + // referenced anchor lists nothing with a zero exit when empty, so an error + // here is a real read failure. + return nil, nil, err + } + rules, raw = f.parseAnchorRules(out) + return rules, raw, nil +} + +// compactRules drops the opaque (nil) placeholder rows parseAnchorRules keeps +// for lines it cannot model, leaving only the rules the library represents. The +// read/number and backup paths use it, since those operate on the modeled rule set +// (a []*Rule cannot carry an unparseable line). +func (f *PF) compactRules(rules []*Rule) []*Rule { + out := make([]*Rule, 0, len(rules)) + for _, r := range rules { + if r != nil { + out = append(out, r) + } + } + return out +} + +// listForeignRules returns best-effort filter rules loaded outside this backend's +// own anchor — the main ruleset and any other anchors. pf has no JSON mode and +// foreign rules may use constructs the library's Rule model cannot represent, so +// any line that fails to parse is skipped rather than erroring the read. Callers +// gain visibility of rules in other anchors alongside the library's own. +func (f *PF) listForeignRules(ctx context.Context) []*Rule { + var rules []*Rule + // table records where each foreign rule came from ("" for the main ruleset, the + // anchor name otherwise); it is not ours, so HasPrefix stays false. + parse := func(out []string, table string) { + for _, line := range out { + line = strings.TrimSpace(line) + if line == "" { + continue + } + rule, perr := f.UnmarshalRule(line) + if perr != nil || rule == nil { + continue + } + rule.table = table + rules = append(rules, rule) + } + } + // The main ruleset (our anchor appears only as an `anchor "..."` placeholder + // line here, which does not parse as a rule, so there is no overlap). + if out, err := runCommand(ctx, "pfctl", "-sr"); err == nil { + parse(out, "") + } + // Every other anchor; our own is already read precisely by anchorRules. + if names, err := runCommand(ctx, "pfctl", "-s", "Anchors"); err == nil { + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" || name == f.anchor { + continue + } + if out, err := runCommand(ctx, "pfctl", "-a", name, "-sr"); err == nil { + parse(out, name) + } + } + } + return rules +} + +// GetRules returns the existing filter rules from the zone. +func (f *PF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + rules, _, err = f.anchorRules(ctx) + if err != nil { + return nil, err + } + // Drop the opaque placeholder rows kept for unmodeled anchor lines; GetRules + // reports only the rules the library can represent. + rules = f.compactRules(rules) + // Report one rule per anchor row. A pf rule written without `inet`/`inet6` matches + // both families, and UnmarshalRule reports it as FamilyAny from that single row; + // the transport and direction axes have no both-states form in pf's grammar (this + // backend fans TCPUDP and DirAny out on write), so those rows read back concrete. + // Number the anchor's rules as one ordered list — pf evaluates a single filter + // list, so its position spans directions. Foreign rules appended below live + // outside this anchor and keep Number 0. + numberSequential(rules) + rules = append(rules, f.listForeignRules(ctx)...) + return rules, nil +} + +// --- filter rule encoding ----------------------------------------------------- +// +// The address, port and protocol token helpers here are shared with the NAT +// marshaller. + +// addrToken renders a source/destination value for a pf rule: a bare address, or +// a table reference `` when the token names an address set. The caller emits +// any leading "!" negation separately. +func (f *PF) addrToken(bare string) string { + if _, ok := canonAddr(bare); !ok { + return "<" + bare + ">" + } + return bare +} + +// portMember renders one port spec in pf syntax: "80" for a single port or +// "1000:2000" for a range. +func (f *PF) portMember(pr PortRange) 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) +} + +// portExpr renders a destination port match: a bare value for a single spec or +// a pf list `{ 80 443 1000:2000 }` for several. +func (f *PF) portExpr(specs []PortRange) string { + if len(specs) == 1 { + return f.portMember(specs[0]) + } + members := make([]string, len(specs)) + for i, pr := range specs { + members[i] = f.portMember(pr) + } + return "{ " + strings.Join(members, " ") + " }" +} + +// protoName returns the protocol keyword pf uses; pf spells ICMPv6 as +// `icmp6`. +func (f *PF) protoName(p Protocol) string { + if p == ICMPv6 { + return "icmp6" + } + return p.String() +} + +// rateUnitSeconds converts a RateUnit to the number of seconds pf expresses a +// rate over (pf writes max-src-conn-rate as /). +func (f *PF) rateUnitSeconds(u RateUnit) int { + switch u { + case PerMinute: + return 60 + case PerHour: + return 3600 + case PerDay: + return 86400 + } + return 1 +} + +// MarshalRule encodes a rule as a pf rule line suitable for loading into our +// anchor. Rules are marked `quick` so the first match wins, matching the +// allow/deny-list semantics of the other backends. It is a pure encoder: +// callers run validateRule on the fanned-out row first. +func (f *PF) MarshalRule(r *Rule) (string, error) { + var parts []string + + // Action. + switch r.Action { + case Accept: + parts = append(parts, "pass") + case Drop: + parts = append(parts, "block", "drop") + case Reject: + parts = append(parts, "block", "return") + } + + // Direction. + if r.IsOutput() { + parts = append(parts, "out") + } else { + parts = append(parts, "in") + } + + // Logging, emitted right after the direction as pfctl normalizes it. Packet + // capture requires a pflog interface; the rule syntax is valid regardless. + if r.Log { + parts = append(parts, "log") + } + + // First match wins. + parts = append(parts, "quick") + + // Interface, bound to the rule's direction. + iface := r.InInterface + if r.IsOutput() { + iface = r.OutInterface + } + if iface != "" { + parts = append(parts, "on", iface) + } + + // Address family. pf requires an explicit family for an icmp-type/icmp6-type + // match, and an ICMP protocol implies one (ICMP => inet, ICMPv6 => inet6), so + // resolve it rather than emitting the family only when set explicitly. + switch r.impliedFamily() { + case IPv4: + parts = append(parts, "inet") + case IPv6: + parts = append(parts, "inet6") + } + + // Protocol. + if r.Proto != ProtocolAny { + parts = append(parts, "proto", f.protoName(r.Proto)) + } + + // Source and optional source port(s). + srcSpecs := r.SourcePortSpecs() + if r.Source != "" { + // A non-address token names a pf table, referenced as ``. + neg, bare := splitAddrNeg(r.Source) + if neg { + parts = append(parts, "from", "!", f.addrToken(bare)) + } else { + parts = append(parts, "from", f.addrToken(bare)) + } + if len(srcSpecs) > 0 { + parts = append(parts, "port", f.portExpr(srcSpecs)) + } + } else if len(srcSpecs) > 0 { + parts = append(parts, "from", "any", "port", f.portExpr(srcSpecs)) + } else { + parts = append(parts, "from", "any") + } + + // Destination and optional destination port(s). + dstSpecs := r.PortSpecs() + if r.Destination != "" { + neg, bare := splitAddrNeg(r.Destination) + if neg { + parts = append(parts, "to", "!", f.addrToken(bare)) + } else { + parts = append(parts, "to", f.addrToken(bare)) + } + if len(dstSpecs) > 0 { + parts = append(parts, "port", f.portExpr(dstSpecs)) + } + } else if len(dstSpecs) > 0 { + parts = append(parts, "to", "any", "port", f.portExpr(dstSpecs)) + } else { + parts = append(parts, "to", "any") + } + + // An ICMP type match. pf places it after the from/to addresses (and requires + // the address family emitted above); it spells the ICMPv6 keyword icmp6-type. + if r.Proto.IsICMP() && r.ICMPType != nil { + kw := "icmp-type" + if r.Proto == ICMPv6 { + kw = "icmp6-type" + } + parts = append(parts, kw, strconv.FormatUint(uint64(*r.ICMPType), 10)) + } + + // Rate / connection limits. pf expresses these as per-source state-tracking + // options; validateRule has confirmed the rule is a stateful accept rule with a + // per-source connection limit and no rate-limit burst. + if r.RateLimit != nil || r.ConnLimit != nil { + var opts []string + if r.ConnLimit != nil { + opts = append(opts, fmt.Sprintf("max-src-conn %d", r.ConnLimit.Count)) + } + if r.RateLimit != nil { + opts = append(opts, fmt.Sprintf("max-src-conn-rate %d/%d", r.RateLimit.Rate, f.rateUnitSeconds(r.RateLimit.Unit))) + } + parts = append(parts, "keep", "state", "("+strings.Join(opts, ", ")+")") + } + + // An optional user comment, carried as a pf rule label. It has no effect on + // matching and is ignored when comparing rules. + if r.Comment != "" { + parts = append(parts, "label", strconv.Quote(r.Comment)) + } + + return strings.Join(parts, " "), nil +} + +// --- filter rule operations --------------------------------------------------- + +// validateRule reports whether pf can express the filter rule, applying the +// universal Rule.validate and then the pf-specific shape constraints, so +// MarshalRule renders a rule already known to be expressible. +func (f *PF) validateRule(r *Rule) error { + if err := r.validate(); err != nil { + return err + } + // pf filters by the interface a packet passes on (`pass in`/`pass out`) and has + // no distinct forward chain, so a forward rule cannot be expressed in this model. + if r.IsForward() { + return unsupportedForward("pf") + } + // pf has no both-transports rule form; pfctl expands a `{ tcp udp }` list into + // separate rows on load, so a TCPUDP rule must be fanned into a tcp row and a udp + // row by expandProtocols before it reaches this row-level marshaller. Reaching + // here with TCPUDP means that fan-out was skipped. + if err := r.CheckExpandedProtocol(); err != nil { + return err + } + // pfctl expands a discrete port list into one rule per port on load, so a list + // has no single-row form; AddRule/RemoveRule fan one into a row per spec with + // expandPorts before this row-level marshaller runs (a contiguous range is one + // token and is allowed). A list reaching here means that fan-out was skipped. + if len(r.SourcePortSpecs()) > 1 { + return fmt.Errorf("pf cannot express a source-port list as a single rule: %w", ErrUnsupportedSourcePort) + } + if len(r.PortSpecs()) > 1 { + return fmt.Errorf("pf cannot express a destination-port list as a single rule: %w", ErrUnsupported) + } + // pf keeps state on pass rules automatically; it has no equivalent of the + // conntrack-state match the model exposes, so reject rather than drop it. + if r.State != 0 { + return fmt.Errorf("pf does not support connection-state matching: %w", ErrUnsupportedState) + } + // pf's `log` keyword carries no text prefix, so a LogPrefix cannot be represented. + if r.LogPrefix != "" { + return fmt.Errorf("pf does not support a log prefix: %w", ErrUnsupportedLog) + } + // A pf rule binds a single interface via `on`, tied to the rule direction; the + // pairing that enforces is the shared one Rule.validate holds. Callers validate + // the fanned-out cells, so each carries a concrete direction on arrival. + + // The rule must carry a valid verdict. + switch r.Action { + case Accept, Drop, Reject: + default: + return fmt.Errorf("no valid action was provided") + } + // pf expresses rate/connection limits as per-source state-tracking options, + // valid only on stateful pass (accept) rules. + if r.RateLimit != nil || r.ConnLimit != nil { + if r.Action != Accept { + return fmt.Errorf("pf rate/connection limiting is only supported on accept rules: %w", ErrUnsupported) + } + if r.ConnLimit != nil && !r.ConnLimit.PerSource { + return fmt.Errorf("pf connection limiting is per-source only: %w", ErrUnsupportedConnLimit) + } + // pf's max-src-conn-rate has no burst term, so a requested burst cannot be + // honored; a rule that dropped it would read back with Burst 0 and fail + // rule-identity comparison. + if r.RateLimit != nil && r.RateLimit.Burst != 0 { + return fmt.Errorf("pf does not support a rate-limit burst: %w", ErrUnsupported) + } + } + return nil +} + +// ruleCells fans a logical rule out into the concrete anchor rows it occupies: +// the DirAny direction split times the TCPUDP transport split times the port-list +// split. pf stores each direction, transport and port spec as its own row — +// pfctl expands a `{ tcp udp }` or `port { 80 443 }` list into separate rows on +// load — so a merged rule has no single-row form. The stored rows cover the +// merged rule through Covers, so it round-trips at the set level. +func (f *PF) ruleCells(r *Rule) []*Rule { + var cells []*Rule + for _, dir := range expandDirections(r) { + for _, proto := range expandProtocols(dir) { + cells = append(cells, expandPorts(proto)...) + } + } + return cells +} + +// editAnchor applies one logical-rule edit to the anchor's filter ruleset. The +// rule is fanned out once into its concrete cells (ruleCells) and op rewrites the +// anchor's rows covering every cell in a single pass, so the whole edit lands in +// one `pfctl -f` load. Fanning out here rather than re-entering the public entry +// point per cell is what keeps a rule spanning directions, transports or ports +// from being left half-applied by a failure part-way through, and holds the anchor +// to one read-rewrite cycle instead of one per cell. The rows are 1:1 with raw, +// opaque (nil) rows included, so op can edit physical rows without misaligning +// them. The translation rows are read only when op reports a change, since +// loadAnchor rewrites the whole anchor and must preserve them. +func (f *PF) editAnchor(ctx context.Context, r *Rule, op func(rules []*Rule, raw []string, cells []*Rule) ([]string, bool, error)) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + rules, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + out, changed, err := op(rules, filterRaw, f.ruleCells(r)) + if err != nil { + return err + } + if !changed { + return nil + } + + // Preserve any translation rules that share the anchor. + _, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + return f.loadAnchor(ctx, natRaw, out) +} + +// insertRows returns the anchor's filter rows with each cell's line spliced in +// ahead of the first row at reports true for, appending the cells no row selects. +// A cell an existing row already covers is left alone rather than duplicated, +// which also fills in a subset left by an earlier partial edit instead of +// re-adding the rule whole. It is the shared body of AddRule and InsertRule; each +// passes its own placement predicate over the physical row index. +func (f *PF) insertRows(rules []*Rule, raw []string, cells []*Rule, at func(idx int, cell *Rule) bool) ([]string, bool, error) { + // Validate and encode every cell up front so a rejection or a marshalling + // error changes nothing. The check runs per cell rather than on the caller's + // rule because editAnchor has already fanned the merged axes out — a TCPUDP or + // port-list rule is legitimate on the way in and only its concrete cells are + // expressible. + lines := make([]string, len(cells)) + for i, cell := range cells { + if err := f.validateRule(cell); err != nil { + return nil, false, err + } + line, err := f.MarshalRule(cell) + if err != nil { + return nil, false, err + } + lines[i] = line + } + + // Note the cells the anchor already holds. Match with EqualForDedup, not the + // exact Equal: an existing row whose family, direction, transport or port axis + // spans the cell already covers it, and re-adding a covered cell would leave a + // redundant row Sync then reports forever. The coverage is one-way, so an + // opposite-family twin is never mistaken for a duplicate — adding an IPv6 rule + // whose IPv4 twin exists must still write the IPv6 row. An opaque (nil) row + // models nothing and so covers nothing. + placed := make([]bool, len(cells)) + for _, e := range rules { + if e == nil { + continue + } + for i, cell := range cells { + if !placed[i] && e.EqualForDedup(cell, true) { + placed[i] = true + } + } + } + + out := make([]string, 0, len(raw)+len(cells)) + changed := false + for i, line := range raw { + for j, cell := range cells { + if !placed[j] && at(i, cell) { + out = append(out, lines[j]) + placed[j] = true + changed = true + } + } + out = append(out, line) + } + // A cell no row selected lands at the end of the anchor: AddRule appends by + // design, and an insert position past the last row appends too. + for j := range cells { + if !placed[j] { + out = append(out, lines[j]) + changed = true + } + } + return out, changed, nil +} + +// AddRule adds a rule to the zone, appending it to the anchor's filter ruleset. +func (f *PF) AddRule(ctx context.Context, zoneName string, r *Rule) error { + return f.editAnchor(ctx, r, func(rules []*Rule, raw []string, cells []*Rule) ([]string, bool, error) { + // No row is a placement point, so every missing cell appends. + return f.insertRows(rules, raw, cells, func(int, *Rule) bool { return false }) + }) +} + +// filterAnchors maps each logical filter rule to its physical row index in the +// anchor, skipping opaque (nil) rows so an unmodeled foreign line occupying a +// physical slot does not consume a logical position. Every modeled row is its own +// rule — GetRules reports the anchor row for row — so with no opaque rows this is +// the identity. It backs the logical-position insert/move mapping. +func (f *PF) filterAnchors(rules []*Rule) []int { + anchors := make([]int, 0, len(rules)) + for i, r := range rules { + if r == nil { + continue + } + anchors = append(anchors, i) + } + return anchors +} + +// logicalInsertIndex maps a 1-based logical position (a rule's Number, as GetRules +// reports it) to the 0-based physical anchor index to insert before, given the +// physical index of each logical rule (from filterAnchors/natAnchors) and the +// physical row count. A position past the last logical rule appends (returns +// physicalLen). It exists because the anchor may hold opaque (nil) rows for +// unmodeled foreign lines that occupy a physical slot without consuming a logical +// position, so a logical position counted over the reported rules lands at the +// wrong physical row unless mapped through the anchors. With no opaque rows this +// reduces to position-1, the plain physical index. +func (f *PF) logicalInsertIndex(anchors []int, physicalLen, position int) int { + if position < 1 { + position = 1 + } + if position-1 >= len(anchors) { + return physicalLen + } + return anchors[position-1] +} + +// InsertRule inserts rule before the given 1-based position. position <= 0 is +// treated as 1; a position larger than the current rule count appends the rule. +// A merged rule occupies a row per cell, and its cells go in together at the +// requested position in the order ruleCells fans them out. +func (f *PF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + if position <= 0 { + position = 1 + } + return f.editAnchor(ctx, r, func(rules []*Rule, raw []string, cells []*Rule) ([]string, bool, error) { + // position is a Number GetRules reported, which counts only the rules it can + // model. rules is 1:1 with raw, and filterAnchors skips any opaque + // (unmodeled) row so a foreign anchor line does not consume a logical + // position. A position past the last rule maps past the last row, which + // selects nothing and leaves insertRows to append. + idx := f.logicalInsertIndex(f.filterAnchors(rules), len(raw), position) + return f.insertRows(rules, raw, cells, func(i int, _ *Rule) bool { return i == idx }) + }) +} + +// reorderRows returns the anchor's filter rows with every physical row matching r +// relocated to the 1-based position, and whether any row moved. A FamilyAny or +// TCPUDP target spans rows the anchor may hold separately, so every matching row is +// relocated together. rules is 1:1 with filterRaw. The target position counts only +// modeled rules, so it is mapped to a physical index within the reduced row set. +func (f *PF) reorderRows(rules []*Rule, filterRaw []string, r *Rule, position int) ([]string, bool) { + if position <= 0 { + position = 1 + } + // Split the rows into the ones being moved and the ones staying, keeping the + // kept rules 1:1 with the kept rows for the anchor mapping. Match with + // EqualForRemoval (as RemoveRule does), not the family-strict Equal, so a + // FamilyAny target relocates the family-agnostic row it names; a concrete-family + // target still moves only its own family row. + moved := make([]string, 0, 2) + kept := make([]string, 0, len(filterRaw)) + keptRules := make([]*Rule, 0, len(rules)) + for i, e := range rules { + // An opaque (nil) row is never a match target, so it is always kept in place. + if e != nil && e.EqualForRemoval(r, true) { + moved = append(moved, filterRaw[i]) + continue + } + kept = append(kept, filterRaw[i]) + keptRules = append(keptRules, rules[i]) + } + if len(moved) == 0 { + return nil, false + } + newIdx := f.logicalInsertIndex(f.filterAnchors(keptRules), len(kept), position) + out := make([]string, 0, len(filterRaw)) + out = append(out, kept[:newIdx]...) + out = append(out, moved...) + out = append(out, kept[newIdx:]...) + return out, true +} + +// MoveRule moves an existing rule to the given 1-based position. +func (f *PF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + // The cells go unused here, unlike the other edits: reorderRows matches with + // EqualForRemoval, which already spans the merged axes, and pf evaluates a + // single ordered filter list, so every row the rule occupies relocates together + // as one block rather than cell by cell landing at the same position in turn. + return f.editAnchor(ctx, r, func(rules []*Rule, raw []string, _ []*Rule) ([]string, bool, error) { + out, moved := f.reorderRows(rules, raw, r, position) + return out, moved, nil + }) +} + +// removeRows returns the anchor's filter rows with every row matching one of the +// rule's cells dropped, reporting whether any matched. Match with EqualForRemoval +// rather than the family-strict Equal, so a FamilyAny cell clears every row it +// covers — an unpinned dual-family row and any family-pinned twins added one at a +// time — while a concrete-family cell still removes only its own family and never +// the twin's row. Every row a cell matches goes in the one pass, so an anchor +// holding the same rule twice comes back clean. +func (f *PF) removeRows(rules []*Rule, raw []string, cells []*Rule) ([]string, bool, error) { + kept := make([]string, 0, len(raw)) + changed := false + for i, e := range rules { + // An opaque (nil) row models nothing, so it is never a match target and is + // always preserved. + matched := false + var split *Rule + if e != nil { + for _, cell := range cells { + if e.EqualForRemoval(cell, true) { + matched = true + // A concrete-family cell that matched a genuine dual-family row (an + // anchor rule with no af, covering both) would drop both families; + // the untargeted family is re-marshalled below. + split = splitDualRow(e, cell) + break + } + } + } + if !matched { + kept = append(kept, raw[i]) + continue + } + changed = true + if split == nil { + continue + } + // The surviving family takes the dual row's own slot, so it keeps both its + // coverage and its place in the anchor. It is synthesized by the split rather + // than supplied by a caller, so it takes the check an entry point would run. + if err := f.validateRule(split); err != nil { + return nil, false, err + } + line, err := f.MarshalRule(split) + if err != nil { + return nil, false, err + } + kept = append(kept, line) + } + return kept, changed, nil +} + +// RemoveRule removes a rule from the zone. The cells are the inverse of the add +// fan-out: EqualForRemoval matches ports exactly, so a port-list target clears the +// per-spec rows AddRule wrote for it. +func (f *PF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + return f.editAnchor(ctx, r, f.removeRows) +} + +// --- NAT rule parsing and anchor reading -------------------------------------- + +// UnmarshalNATRule decodes a single pf nat/rdr rule line as produced by +// `pfctl -a -sn`. +func (f *PF) UnmarshalNATRule(line string) (*NATRule, error) { + tokens := strings.Fields(line) + if len(tokens) == 0 { + return nil, fmt.Errorf("empty rule") + } + + r := new(NATRule) + i := 0 + switch tokens[i] { + case "rdr": + r.Kind = DNAT + case "nat": + r.Kind = SNAT // Refined to Masquerade below if the target is dynamic. + default: + return nil, fmt.Errorf("unsupported nat action: %s", tokens[i]) + } + i++ + + // srcScope is true between `from` and `to`, where a `port` token is a + // source-port match this model cannot hold; erroring keeps the line opaque + // rather than mis-reading it as a destination-port rule. + srcScope := false + for ; i < len(tokens); i++ { + switch tokens[i] { + case "pass", "quick", "log": + // Qualifiers with no bearing on our model. + case "all": + // pfctl prints `all` for `from any to any`. + case "round-robin", "random", "source-hash", "bitmask", "static-port", "sticky-address": + // Address-pool / port options pfctl appends to a nat rule; ignored. + case "on": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing interface value") + } + r.Interface = tokens[i] + case "inet": + r.Family = IPv4 + case "inet6": + r.Family = IPv6 + case "proto": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing protocol value") + } + r.Proto = GetProtocol(tokens[i]) + if r.Proto == ProtocolAny { + return nil, fmt.Errorf("unsupported protocol: %s", tokens[i]) + } + case "from": + srcScope = true + val, neg, next, err := f.parseAddr(tokens, i+1) + if err != nil { + return nil, err + } + i = next + if val != "any" { + r.Source = neg + f.stripTable(val) + } + case "to": + srcScope = false + val, neg, next, err := f.parseAddr(tokens, i+1) + if err != nil { + return nil, err + } + i = next + if val != "any" { + r.Destination = neg + f.stripTable(val) + } + case "port": + if srcScope { + return nil, fmt.Errorf("source port match is not modeled") + } + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing port value") + } + if tokens[i] == "=" { + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing port value") + } + } + specs, next, err := f.parsePorts(tokens, i) + if err != nil { + return nil, err + } + i = next + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.Port = specs[0].Start + } else { + r.Ports = specs + } + case "->": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("missing nat target") + } + target := tokens[i] + if strings.HasPrefix(target, "(") { + // A dynamic interface address is masquerade. A port pool after it + // (`port 1024:65535`) has no single ToPort to map onto, so the line + // stays opaque. + r.Kind = Masquerade + if i+1 < len(tokens) && tokens[i+1] == "port" { + return nil, fmt.Errorf("nat target port pool is not modeled") + } + } else { + r.ToAddress = target + // An optional `port N` gives the translation port. pfctl prints a + // well-known target port by its /etc/services name (80 -> http), just + // like a match port, so resolve it through lookupPort rather than a + // number-only parse — otherwise a named target port fails to parse and + // the whole rule is dropped from the snapshot. + if i+2 < len(tokens) && tokens[i+1] == "port" { + p, err := f.lookupPort(tokens[i+2]) + if err != nil { + return nil, fmt.Errorf("invalid nat target port %q", tokens[i+2]) + } + r.ToPort = p + i += 2 + } + } + default: + return nil, fmt.Errorf("unsupported token: %s", tokens[i]) + } + } + + if r.Family == FamilyAny { + r.Family = r.impliedFamily() + } + if r.Kind == NATInvalid { + return nil, fmt.Errorf("no nat action was provided") + } + return r, nil +} + +// anchorNATRules returns the nat/rdr rules currently loaded in our anchor. +func (f *PF) anchorNATRules(ctx context.Context) (rules []*NATRule, raw []string, err error) { + out, err := runCommand(ctx, "pfctl", "-a", f.anchor, "-sn") + if err != nil { + // Propagate a genuine failure rather than a false-empty snapshot; see + // anchorRules for why swallowing it risks silently dropping loaded rules. + return nil, nil, err + } + for _, line := range out { + line = strings.TrimSpace(line) + if line == "" { + continue + } + rule, perr := f.UnmarshalNATRule(line) + if perr != nil { + // Preserve a line we cannot model as an opaque row; see parseAnchorRules + // for why nil keeps rules 1:1 with raw so a rewrite does not drop it. + rules = append(rules, nil) + raw = append(raw, line) + continue + } + // NAT rules loaded in this backend's own anchor: membership is what sets + // HasPrefix, so record the anchor and flag it as carrying the prefix. + rule.table = f.anchor + rule.HasPrefix = true + rules = append(rules, rule) + raw = append(raw, line) + } + return rules, raw, nil +} + +// compactNATRules is compactRules for NAT rules: it drops the opaque (nil) +// placeholder rows anchorNATRules keeps for unmodeled lines. +func (f *PF) compactNATRules(rules []*NATRule) []*NATRule { + out := make([]*NATRule, 0, len(rules)) + for _, r := range rules { + if r != nil { + out = append(out, r) + } + } + return out +} + +// listForeignNATRules returns best-effort nat/rdr rules loaded outside this +// backend's own anchor — the main ruleset and any other anchors. Unparseable +// lines are skipped, as in listForeignRules. +func (f *PF) listForeignNATRules(ctx context.Context) []*NATRule { + var rules []*NATRule + // table records where each foreign NAT rule came from ("" for the main ruleset, + // the anchor name otherwise); not ours, so HasPrefix stays false. + parse := func(out []string, table string) { + for _, line := range out { + line = strings.TrimSpace(line) + if line == "" { + continue + } + rule, perr := f.UnmarshalNATRule(line) + if perr != nil || rule == nil { + continue + } + rule.table = table + rules = append(rules, rule) + } + } + if out, err := runCommand(ctx, "pfctl", "-sn"); err == nil { + parse(out, "") + } + if names, err := runCommand(ctx, "pfctl", "-s", "Anchors"); err == nil { + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" || name == f.anchor { + continue + } + if out, err := runCommand(ctx, "pfctl", "-a", name, "-sn"); err == nil { + parse(out, name) + } + } + } + return rules +} + +// GetNATRules returns the existing NAT rules from the zone. +func (f *PF) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { + rules, _, err = f.anchorNATRules(ctx) + if err != nil { + return nil, err + } + // Drop the opaque placeholder rows kept for unmodeled anchor lines. + rules = f.compactNATRules(rules) + // Report one rule per anchor row: a nat/rdr line written without `inet`/`inet6` + // matches both families and reads back as FamilyAny on its own. Number the + // anchor's NAT rules as one ordered list; foreign NAT rules appended below keep + // Number 0. + numberNATSequential(rules) + rules = append(rules, f.listForeignNATRules(ctx)...) + return rules, nil +} + +// --- NAT rule encoding -------------------------------------------------------- + +// MarshalNATRule encodes a NAT rule as a pf rdr/nat rule line for our anchor. +// It is a pure encoder: callers run validateNAT on the fanned-out row first. +func (f *PF) MarshalNATRule(r *NATRule) (string, error) { + var parts []string + switch r.Kind { + case DNAT: + parts = append(parts, "rdr") + case SNAT, Masquerade: + parts = append(parts, "nat") + } + + // Interface, bound to the translation direction. + if r.Interface != "" { + parts = append(parts, "on", r.Interface) + } + + switch r.impliedFamily() { + case IPv4: + parts = append(parts, "inet") + case IPv6: + parts = append(parts, "inet6") + } + + if r.Proto != ProtocolAny { + parts = append(parts, "proto", f.protoName(r.Proto)) + } + + if r.Source != "" { + neg, bare := splitAddrNeg(r.Source) + if neg { + parts = append(parts, "from", "!", f.addrToken(bare)) + } else { + parts = append(parts, "from", f.addrToken(bare)) + } + } else { + parts = append(parts, "from", "any") + } + if r.Destination != "" { + neg, bare := splitAddrNeg(r.Destination) + if neg { + parts = append(parts, "to", "!", f.addrToken(bare)) + } else { + parts = append(parts, "to", f.addrToken(bare)) + } + } else { + parts = append(parts, "to", "any") + } + + if specs := r.PortSpecs(); len(specs) > 0 { + parts = append(parts, "port", f.portExpr(specs)) + } + + // Translation target. + switch r.Kind { + case DNAT: + parts = append(parts, "->", r.ToAddress) + if r.ToPort != 0 { + parts = append(parts, "port", strconv.FormatUint(uint64(r.ToPort), 10)) + } + case SNAT: + // validateNAT has rejected a source-port translation, which pf's nat rule + // cannot carry (it maps to an address only). + parts = append(parts, "->", r.ToAddress) + case Masquerade: + // validateNAT guarantees an interface, which pf renders as `-> (iface)`. + parts = append(parts, "->", "("+r.Interface+")") + } + + return strings.Join(parts, " "), nil +} + +// --- NAT rule operations ------------------------------------------------------ + +// validateNAT reports whether pf can express the NAT rule, applying the universal +// NATRule.validate and then the pf-specific constraints, so MarshalNATRule renders +// a rule already known to be expressible. +func (f *PF) validateNAT(r *NATRule) error { + if err := r.validate(); err != nil { + return err + } + // pfctl expands a discrete match-port list into one rule per port on load, so + // a list has no single-row form; AddNATRule/RemoveNATRule fan one into a row + // per spec with expandNATPorts before this row-level validation runs (a + // contiguous range is one token and is allowed). A list reaching here means + // that fan-out was skipped. + if len(r.PortSpecs()) > 1 { + return fmt.Errorf("pf cannot express a NAT match-port list as a single rule: %w", ErrUnsupported) + } + switch r.Kind { + case Redirect: + // pf has no portless redirect target; a redirect is modeled as a dnat to a + // local address. + return fmt.Errorf("pf does not support a portless redirect; use dnat to a local address: %w", ErrUnsupportedNAT) + case SNAT: + // pf's nat rule maps to an address only; a source-port translation has no + // representation here. (iptables emits it as --to-source addr:port.) + if r.ToPort != 0 { + return fmt.Errorf("pf does not support translating the source port in snat: %w", ErrUnsupportedNAT) + } + case Masquerade: + // pf renders masquerade as `-> (iface)`, so it requires an interface. + if r.Interface == "" { + return fmt.Errorf("pf masquerade requires an interface") + } + } + return nil +} + +// editNATAnchor is editAnchor for NAT rules. The rule is fanned out once into +// the translation rows it occupies — one per match-port spec, since pfctl expands +// a `port { 80 443 }` list into separate rows on load — and op rewrites the +// anchor's rows covering every cell in a single pass, so the whole edit lands in +// one `pfctl -f` load and cannot be left half-applied. The rows are 1:1 with raw, +// opaque (nil) rows included. The filter rows are read only when op reports a +// change, since loadAnchor rewrites the whole anchor and must preserve them. +func (f *PF) editNATAnchor(ctx context.Context, r *NATRule, op func(rules []*NATRule, raw []string, cells []*NATRule) ([]string, bool, error)) error { + if err := f.ensureNATAnchors(ctx); err != nil { + return err + } + + rules, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + out, changed, err := op(rules, natRaw, expandNATPorts(r)) + if err != nil { + return err + } + if !changed { + return nil + } + + // Preserve the filter rules that share the anchor. + _, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + return f.loadAnchor(ctx, out, filterRaw) +} + +// insertNATRows is insertRows for NAT rules: it returns the anchor's translation +// rows with each cell's line spliced in ahead of the first row at reports true +// for, appending the cells no row selects and leaving a cell an existing row +// already covers alone. It is the shared body of AddNATRule and InsertNATRule. +func (f *PF) insertNATRows(rules []*NATRule, raw []string, cells []*NATRule, at func(idx int, cell *NATRule) bool) ([]string, bool, error) { + // Validate and encode every cell up front so a rejection changes nothing. The + // check runs per cell because editNATAnchor has already split the match-port + // list a caller may legitimately pass in. + lines := make([]string, len(cells)) + for i, cell := range cells { + if err := f.validateNAT(cell); err != nil { + return nil, false, err + } + line, err := f.MarshalNATRule(cell) + if err != nil { + return nil, false, err + } + lines[i] = line + } + + // Dedup only against a row that also covers this cell's family (EqualForDedup): + // without the coverage gate, adding an IPv6 NAT rule whose otherwise-identical + // IPv4 twin already exists (e.g. a per-interface masquerade) would be silently + // dropped, leaving that family un-NATed. + placed := make([]bool, len(cells)) + for _, e := range rules { + if e == nil { + continue + } + for i, cell := range cells { + if !placed[i] && e.EqualForDedup(cell) { + placed[i] = true + } + } + } + + out := make([]string, 0, len(raw)+len(cells)) + changed := false + for i, line := range raw { + for j, cell := range cells { + if !placed[j] && at(i, cell) { + out = append(out, lines[j]) + placed[j] = true + changed = true + } + } + out = append(out, line) + } + // A cell no row selected lands at the end of the translation ruleset. + for j := range cells { + if !placed[j] { + out = append(out, lines[j]) + changed = true + } + } + return out, changed, nil +} + +// AddNATRule adds a NAT rule to the zone, appending it to the anchor's +// translation ruleset. +func (f *PF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + return f.editNATAnchor(ctx, r, func(rules []*NATRule, raw []string, cells []*NATRule) ([]string, bool, error) { + // No row is a placement point, so every missing cell appends. + return f.insertNATRows(rules, raw, cells, func(int, *NATRule) bool { return false }) + }) +} + +// natAnchors is filterAnchors for NAT rules: it maps each logical NAT rule to its +// physical row index, skipping opaque (nil) rows. +func (f *PF) natAnchors(rules []*NATRule) []int { + anchors := make([]int, 0, len(rules)) + for i, r := range rules { + if r == nil { + continue + } + anchors = append(anchors, i) + } + return anchors +} + +// InsertNATRule inserts a NAT rule at the given 1-based position within the +// anchor's NAT ruleset. position <= 0 is treated as 1; a position larger than the +// current NAT rule count appends the rule. +func (f *PF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + if position <= 0 { + position = 1 + } + return f.editNATAnchor(ctx, r, func(rules []*NATRule, raw []string, cells []*NATRule) ([]string, bool, error) { + // position is a Number GetNATRules reported, which counts only the rules it + // can model. rules is 1:1 with raw, and natAnchors skips any opaque + // (unmodeled) row so a foreign anchor line does not consume a logical + // position. A position past the last rule selects no row, leaving + // insertNATRows to append. + idx := f.logicalInsertIndex(f.natAnchors(rules), len(raw), position) + return f.insertNATRows(rules, raw, cells, func(i int, _ *NATRule) bool { return i == idx }) + }) +} + +// reorderNATRows is reorderRows for NAT rules: it returns the anchor's nat rows +// with every physical row matching r relocated to the 1-based position, and +// whether any row moved. A FamilyAny target spans rows the anchor may hold +// separately (an IPv4 row and an IPv6 row added one at a time), so every matching +// row is relocated together as a block. rules is 1:1 with natRaw. The target +// position counts only modeled rules, so it is mapped to a physical index within +// the reduced row set. +func (f *PF) reorderNATRows(rules []*NATRule, natRaw []string, r *NATRule, position int) ([]string, bool) { + if position <= 0 { + position = 1 + } + // Split the rows into the ones being moved and the ones staying, keeping the + // kept rules 1:1 with the kept rows for the anchor mapping. Match with + // EqualForRemoval (as RemoveNATRule does) so a FamilyAny target relocates the + // family-agnostic row it names; a concrete-family target still moves only its + // own family row. + moved := make([]string, 0, 2) + kept := make([]string, 0, len(natRaw)) + keptRules := make([]*NATRule, 0, len(rules)) + for i, e := range rules { + // An opaque (nil) row is never a match target, so it is always kept in place. + if e != nil && e.EqualForRemoval(r) { + moved = append(moved, natRaw[i]) + continue + } + kept = append(kept, natRaw[i]) + keptRules = append(keptRules, rules[i]) + } + if len(moved) == 0 { + return nil, false + } + newIdx := f.logicalInsertIndex(f.natAnchors(keptRules), len(kept), position) + out := make([]string, 0, len(natRaw)) + out = append(out, kept[:newIdx]...) + out = append(out, moved...) + out = append(out, kept[newIdx:]...) + return out, true +} + +// MoveNATRule moves an existing NAT rule to the given 1-based position. +func (f *PF) MoveNATRule(ctx context.Context, zoneName string, r *NATRule, position int) error { + // The cells go unused here, as in MoveRule: reorderNATRows matches with + // EqualForRemoval, which already spans the rows a merged rule occupies, so they + // relocate together as one block. + return f.editNATAnchor(ctx, r, func(rules []*NATRule, raw []string, _ []*NATRule) ([]string, bool, error) { + out, moved := f.reorderNATRows(rules, raw, r, position) + return out, moved, nil + }) +} + +// removeNATRows is removeRows for NAT rules: it returns the anchor's translation +// rows with every row matching one of the rule's cells dropped, reporting whether +// any matched. A FamilyAny cell spans rows the anchor may hold separately (an IPv4 +// row and an IPv6 row added one at a time), so removing it clears all of them, +// while a concrete-family cell still removes only its own family and never the +// twin's row. +func (f *PF) removeNATRows(rules []*NATRule, raw []string, cells []*NATRule) ([]string, bool, error) { + kept := make([]string, 0, len(raw)) + changed := false + for i, e := range rules { + // An opaque (nil) row models nothing, so it is never a match target and is + // always preserved. + matched := false + if e != nil { + for _, cell := range cells { + if e.EqualForRemoval(cell) { + matched = true + break + } + } + } + if matched { + changed = true + continue + } + kept = append(kept, raw[i]) + } + return kept, changed, nil +} + +// RemoveNATRule removes a NAT rule from the zone. The cells are the inverse of the +// add fan-out: EqualForRemoval matches ports exactly, so a match-port list target +// clears the per-spec rows AddNATRule wrote for it. +func (f *PF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + return f.editNATAnchor(ctx, r, f.removeNATRows) +} + +// --- default policy ----------------------------------------------------------- + +// GetDefaultPolicy is unsupported; pf exposes no default policy in this model. +func (f *PF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + return nil, unsupportedPolicy(f.Type()) +} + +// SetDefaultPolicy is unsupported; pf exposes no default policy in this model. +func (f *PF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + return unsupportedPolicy(f.Type()) +} + +// --- address sets (pf tables) ------------------------------------------------- + +// isMissingTableErr reports whether a pfctl error means the table does not +// exist. pfctl prints "pfctl: Table does not exist." for a missing table across +// its -T subcommands (show/kill/delete), so every idempotent table operation keys +// on this same string rather than each guessing at the wording. +func (f *PF) isMissingTableErr(err error) bool { + return err != nil && strings.Contains(err.Error(), "does not exist") +} + +// tableFamily infers a table's family from its entries: IPv6 when every entry +// is IPv6, FamilyAny when the table mixes families (a pf table legitimately +// holds both), and IPv4 otherwise. +func (f *PF) tableFamily(entries []string) Family { + v4, v6 := false, false + for _, e := range entries { + if strings.Contains(e, ":") { + v6 = true + } else { + v4 = true + } + } + switch { + case v4 && v6: + return FamilyAny + case v6: + return IPv6 + } + return IPv4 +} + +// getAddressSet reads one pf table, returning (nil, nil) when no table of that +// name exists so the two exported readers can each report a miss their own way. +func (f *PF) getAddressSet(ctx context.Context, name string) (*AddressSet, error) { + out, err := runCommand(ctx, "pfctl", "-t", name, "-T", "show") + if err != nil { + // pfctl reports an unknown table distinctly; only that is a genuine + // "not found" (nil, nil). Any other failure (permission, pf disabled) is a + // real error the caller must see rather than a silent miss. + if f.isMissingTableErr(err) { + return nil, nil + } + return nil, err + } + var entries []string + for _, line := range out { + e := strings.TrimSpace(line) + if e != "" { + entries = append(entries, e) + } + } + return &AddressSet{Name: name, Family: f.tableFamily(entries), Entries: entries}, nil +} + +// GetAddressSets returns the address sets (pf tables) managed by this backend. +func (f *PF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + out, err := runCommand(ctx, "pfctl", "-s", "Tables") + if err != nil { + // A failed listing must surface: reporting "no sets" would let a Backup + // silently capture zero tables and a later Restore load rules whose + // references resolve to nothing. + return nil, err + } + var result []*AddressSet + for _, line := range out { + name := strings.TrimSpace(line) + if name == "" { + continue + } + set, err := f.getAddressSet(ctx, name) + if err != nil { + return nil, err + } + if set == nil { + continue + } + result = append(result, set) + } + return result, nil +} + +// GetAddressSet returns a single address set by name, or an error if it does not exist. +func (f *PF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + set, err := f.getAddressSet(ctx, name) + if err != nil { + return nil, err + } + if set == nil { + return nil, fmt.Errorf("address set %q not found", name) + } + return set, nil +} + +// AddAddressSet creates an address set (pf table), or empties an existing one. +func (f *PF) AddAddressSet(ctx context.Context, set *AddressSet) error { + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + if err := f.ensureAnchor(ctx); err != nil { + return err + } + // `-T replace` reconciles an existing table to exactly the given entries + // (where `-T add` would only union), and with none creates the table empty — + // pf does not lazily create tables, so a later filter rule referencing + // would otherwise fail to load. + args := []string{"-t", set.Name, "-T", "replace"} + args = append(args, set.Entries...) + _, err := runCommand(ctx, "pfctl", args...) + return err +} + +// RemoveAddressSet removes an address set (pf table) by name; an absent table is a no-op. +func (f *PF) RemoveAddressSet(ctx context.Context, name string) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + _, err := runCommand(ctx, "pfctl", "-t", name, "-T", "kill") + // Removing an absent table is a no-op success: pfctl fails with "Table does + // not exist", which must not surface as an error (matching getAddressSet). + if f.isMissingTableErr(err) { + return nil + } + return err +} + +// AddAddressSetEntry adds an entry to the named address set (pf table). +func (f *PF) AddAddressSetEntry(ctx context.Context, name, entry string) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + _, err := runCommand(ctx, "pfctl", "-t", name, "-T", "add", entry) + return err +} + +// RemoveAddressSetEntry removes an entry from the named address set (pf table); an absent table is a no-op. +func (f *PF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + _, err := runCommand(ctx, "pfctl", "-t", name, "-T", "delete", entry) + // Deleting from an absent table is a no-op success (see RemoveAddressSet). + if f.isMissingTableErr(err) { + return nil + } + return err +} + +// --- backup and restore ------------------------------------------------------- + +// Backup captures the current filter and NAT rules managed by this backend. +func (f *PF) Backup(ctx context.Context, zoneName string) (*Backup, error) { + // Read the private anchor directly rather than GetRules: Restore refills only + // this anchor, so the backup must not pull in rules from the main ruleset or + // other anchors (they would be re-loaded into the wrong anchor on Restore). + rules, _, err := f.anchorRules(ctx) + if err != nil { + return nil, err + } + natRules, _, err := f.anchorNATRules(ctx) + if err != nil { + return nil, err + } + // A Backup holds modeled rules ([]*Rule / []*NATRule), which cannot carry an + // unparseable anchor line, so drop the opaque placeholder rows here. + backup := &Backup{Rules: f.compactRules(rules), NATRules: f.compactNATRules(natRules)} + // pf has no default policy to capture (DefaultPolicy is false), so this only + // adds the pf tables a rule may reference. + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil +} + +// marshalExpanded renders rules as anchor filter lines, fanning each into the +// rows it occupies through ruleCells. Restore uses it so a merged rule from a +// portable backup expands exactly as the add paths expand it. +func (f *PF) marshalExpanded(rules []*Rule) ([]string, error) { + var lines []string + for _, top := range rules { + for _, cell := range f.ruleCells(top) { + if err := f.validateRule(cell); err != nil { + return nil, err + } + line, err := f.MarshalRule(cell) + if err != nil { + return nil, err + } + lines = append(lines, line) + } + } + return lines, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *PF) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + // Ensure the pf.conf anchor references exist before loading. When the backup + // carries NAT rules, the nat-anchor/rdr-anchor references must be present too + // (ensureNATAnchors also ensures the filter anchor); without them pf loads the + // translation rules into the anchor but never evaluates them, mirroring the + // AddNATRule/InsertNATRule/MoveNATRule paths. + if len(backup.NATRules) > 0 { + if err := f.ensureNATAnchors(ctx); err != nil { + return err + } + } else if err := f.ensureAnchor(ctx); err != nil { + return err + } + + // Recreate the pf tables a rule may reference (`
`) before loading the + // anchor. pf tables are global and independent of the anchor ruleset, so this + // creates or repopulates them (pfctl -T add) without disturbing the anchor. + if err := restoreBackupSets(ctx, f, backup, false); err != nil { + return err + } + + // A portable backup may carry DirAny or TCPUDP rules captured from a backend + // that stores them as one row; expand exactly as the add paths do. + filterLines, err := f.marshalExpanded(backup.Rules) + if err != nil { + return err + } + + // A match-port list expands exactly as the add paths fan it out: one + // translation row per port spec. + var natLines []string + for _, r := range backup.NATRules { + for _, sub := range expandNATPorts(r) { + if err := f.validateNAT(sub); err != nil { + return err + } + line, err := f.MarshalNATRule(sub) + if err != nil { + return err + } + natLines = append(natLines, line) + } + } + + return f.loadAnchor(ctx, natLines, filterLines) +} + +// Reload is a no-op; pf applies anchor changes immediately. +func (f *PF) Reload(ctx context.Context) error { + return nil +} + +// Close releases any resources held by the backend; pf holds none. +func (f *PF) Close(ctx context.Context) error { + return nil +} diff --git a/pf_test.go b/pf_test.go new file mode 100644 index 0000000..59dceb0 --- /dev/null +++ b/pf_test.go @@ -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 . + {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 ") + + // 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 ".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) +} diff --git a/scripts/refactor_backend.py b/scripts/refactor_backend.py new file mode 100644 index 0000000..9d76d9a --- /dev/null +++ b/scripts/refactor_backend.py @@ -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 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() diff --git a/scripts/refactor_order.py b/scripts/refactor_order.py new file mode 100644 index 0000000..c221a78 --- /dev/null +++ b/scripts/refactor_order.py @@ -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 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"(? 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() diff --git a/services.go b/services.go new file mode 100644 index 0000000..156003d --- /dev/null +++ b/services.go @@ -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 +} diff --git a/test/integration/.gitignore b/test/integration/.gitignore new file mode 100644 index 0000000..1d669dc --- /dev/null +++ b/test/integration/.gitignore @@ -0,0 +1,2 @@ +# Test binary compiled by host-linux-vm.sh and run natively inside the VM. +firewall.test diff --git a/test/integration/guest-linux-run.sh b/test/integration/guest-linux-run.sh new file mode 100755 index 0000000..2b6ae47 --- /dev/null +++ b/test/integration/guest-linux-run.sh @@ -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// (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//, 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// 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// 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// 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 diff --git a/test/integration/host-freebsd-vm.sh b/test/integration/host-freebsd-vm.sh new file mode 100755 index 0000000..6f7c4ee --- /dev/null +++ b/test/integration/host-freebsd-vm.sh @@ -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" <"$work/user-data" </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 "$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 diff --git a/test/integration/host-linux-vm.sh b/test/integration/host-linux-vm.sh new file mode 100755 index 0000000..b4ceaf0 --- /dev/null +++ b/test/integration/host-linux-vm.sh @@ -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" <"$work/user-data" <> 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 "$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 diff --git a/test/integration/host-windows-vm.sh b/test/integration/host-windows-vm.sh new file mode 100755 index 0000000..ffa39d8 --- /dev/null +++ b/test/integration/host-windows-vm.sh @@ -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 — 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" < + + + + en-US + en-USen-US + en-USen-US + + + + + 0true + + 1Primarytrue + + + 11NTFStrue + + + + + + 01 + /IMAGE/INDEX3 + + + true + + + + + WINTEST + + + + + $PASStrue</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" diff --git a/types.go b/types.go new file mode 100644 index 0000000..396491b --- /dev/null +++ b/types.go @@ -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 +} diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..26c2e6a --- /dev/null +++ b/types_test.go @@ -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) + } +} diff --git a/ufw_linux.go b/ufw_linux.go new file mode 100644 index 0000000..3e72cf4 --- /dev/null +++ b/ufw_linux.go @@ -0,0 +1,1897 @@ +package firewall + +import ( + "bufio" + "context" + "encoding/hex" + "fmt" + "io" + "net" + "os" + "strconv" + "strings" +) + +const ( + // UFWIPv4 is ufw's IPv4 user-rules file. + UFWIPv4 = "/etc/ufw/user.rules" + // UFWIPv6 is ufw's IPv6 user-rules file. + UFWIPv6 = "/etc/ufw/user6.rules" + // UFWConf is ufw's main configuration file (ENABLED/LOGLEVEL). + UFWConf = "/etc/ufw/ufw.conf" + // UFWDefaults is ufw's defaults file, where the default-policy keys + // (DEFAULT_INPUT_POLICY, ...) actually live — not ufw.conf. + UFWDefaults = "/etc/default/ufw" + // UFWBefore and its peers are the iptables rules files holding rules that run + // before/after the user rules, in iptables-restore format with ufw's own + // chains. They are the raw-iptables fallback for what ufw's tuple format + // cannot express — ICMP, SCTP, state matches, custom log prefixes, + // rate/connection limits, and NAT (written into before.rules' nat table via + // natHelper). + UFWBefore = "/etc/ufw/before.rules" + UFWBefore6 = "/etc/ufw/before6.rules" + UFWAfter = "/etc/ufw/after.rules" + UFWAfter6 = "/etc/ufw/after6.rules" +) + +// UFW manages a host firewall through the ufw command-line tool and its rules files. +type UFW struct { + // rulePrefix, when set, is attached as a ufw comment on rules this library + // creates so they can be told apart from pre-existing rules. + rulePrefix string + // iptablesRulesChanged records whether a before.rules/before6.rules file was + // edited this session, so Reload knows to run `ufw reload`. + iptablesRulesChanged bool + // sets is the iptables backend ufw reaches its ipset-backed address sets + // through. It is held for the session rather than built per call because it + // carries the staging file the sets are written to and the queue of removals + // Reload still owes the kernel. + sets *IPTables +} + +// NewUFW connects to ufw, verifies it is enabled, and returns a manager for it. +func NewUFW(ctx context.Context, rulePrefix string) (*UFW, error) { + ufw := new(UFW) + ufw.rulePrefix = rulePrefix + + // Confirm ufw is enabled under whatever init system the host uses + // (systemd, chkconfig, update-rc.d, OpenRC, Slackware rc.d, or rc.local). + if !serviceEnabled(ctx, "ufw") { + return nil, fmt.Errorf("the ufw service is not enabled on this server") + } + + // Try and read the ufw config file. + fd, err := os.Open(UFWConf) + if err != nil { + return nil, fmt.Errorf("ufw config is not readable: %w", err) + } + + // Scan file for the enabled state. + scanner := bufio.NewScanner(fd) + enabled := false + for scanner.Scan() { + // Get the line. + line := scanner.Text() + + // Remove comments. + ci := strings.IndexByte(line, '#') + if ci >= 0 { + line = line[:ci] + } + + // Trim spaces. + line = strings.TrimSpace(line) + + // Ignore zero lines. + if len(line) == 0 { + continue + } + + // Parse key/value. + key, val, found := strings.Cut(line, "=") + if !found { + continue + } + key = strings.TrimSpace(key) + val = trimQuotes(strings.TrimSpace(val)) + + // Check if enabled. + if key == "ENABLED" && strings.EqualFold(val, "yes") { + enabled = true + } + } + + // Close file. + _ = fd.Close() + + if err := scanner.Err(); err != nil { + return nil, err + } + + // If disabled, return error. + if !enabled { + return nil, fmt.Errorf("ufw is currently disabled") + } + + // Confirm config files exist. + files := []string{UFWIPv4, UFWIPv6} + for _, f := range files { + if _, err := os.Stat(f); err != nil { + return nil, fmt.Errorf("the config file %s is missing", f) + } + } + + // Build the address-set helper, detecting the optional ipset persistence + // mechanism independently of ufw's own rules files. A missing mechanism is not + // fatal; it leaves sets live-only, as it does for the iptables backend. + ufw.sets = &IPTables{rulePrefix: rulePrefix} + ufw.sets.IPSetPath, ufw.sets.IPSetService = detectIPSetPersistence(ctx) + + // Return the new ufw object. + return ufw, nil +} + +// Type returns the backend identifier for ufw. +func (f *UFW) Type() string { + return UFWType +} + +// Capabilities reports the features this backend supports. +func (f *UFW) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + IPv6: true, + PortPair: true, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: true, + DefaultPolicy: true, + RuleCounters: true, + AddressSets: true, + Comments: true, + Negation: true, + RejectAction: true, + FamilyWithoutAddress: true, + } +} + +// --- default policy --------------------------------------------------------- + +// GetZone reports no zone; ufw has no zone support. +func (f *UFW) GetZone(ctx context.Context, iface string) (zoneName string, err error) { + return "", nil +} + +// ipTablesChain maps a ufw iptables chain to a rule direction, reporting +// whether it is one this backend surfaces. Internal chains (logging, not-local, +// skip-to-policy) are not represented and return ok=false. Both the IPv4 (`ufw-*`) +// and IPv6 (`ufw6-*`) chain names are accepted, since before6.rules declares its +// chains with the `ufw6-` prefix. +func (f *UFW) ipTablesChain(chain string) (dir Direction, ok bool) { + switch chain { + case "ufw-before-input", "ufw-after-input", "ufw-user-input", + "ufw6-before-input", "ufw6-after-input", "ufw6-user-input": + return DirInput, true + case "ufw-before-output", "ufw-after-output", "ufw-user-output", + "ufw6-before-output", "ufw6-after-output", "ufw6-user-output": + return DirOutput, true + case "ufw-before-forward", "ufw-after-forward", "ufw-user-forward", + "ufw6-before-forward", "ufw6-after-forward", "ufw6-user-forward": + return DirForward, true + } + return DirInput, false +} + +// parseIPTablesLine parses a raw before.rules line into the rule it represents +// (one line, so a LOG line yields a rule with Log set and no action), reporting +// whether the line is an input/output/forward iptables rule this model surfaces. +// The ufw chain is rewritten to its INPUT/OUTPUT/FORWARD equivalent so the +// iptables rulespec parser can be reused, and the configured prefix is split off +// the comment so only the user-facing text surfaces. +func (f *UFW) parseIPTablesLine(line string, family Family) (*Rule, bool) { + fields := strings.Fields(line) + if len(fields) < 3 || (fields[0] != "-A" && fields[0] != "--append") { + return nil, false + } + dir, ok := f.ipTablesChain(fields[1]) + if !ok { + return nil, false + } + rule, err := unmarshalIPTablesRule("-A "+iptChainForDirection(dir)+" "+strings.Join(fields[2:], " "), family) + if err != nil { + return nil, false + } + rule.Comment, rule.HasPrefix = prefixedComment(f.rulePrefix, rule.Comment) + return rule, true +} + +// ufwGroup is one physical span of a before.rules file: an iptables rule line — +// with a LOG line and the action line directly under it counting as the one +// logged rule they encode — or any other line on its own. It is the ufw +// counterpart of the iptables backend's iptGroup, and both the read path and the +// file rewrites stream through it, so table scoping and LOG-pair coalescing are +// each defined in exactly one place. +type ufwGroup struct { + // raw preserves the original lines verbatim, so a rewrite copies user + // formatting through and a removal drops a logged rule's two lines together. + raw []string + // table is the table the group's lines sit in ("filter", "nat", ...), or "" + // outside any table. A table header belongs to the table it opens and a + // COMMIT to the table it closes. + table string + // rule is the filter rule the group encodes, nil when it encodes none: a line + // outside *filter, one on a chain this backend does not surface, one the model + // cannot hold, an orphan LOG line, or a line that is not a rule at all. + rule *Rule + // commit marks the group as its table's COMMIT line. + commit bool +} + +// scanBeforeGroups streams a before.rules file to fn as logical groups in file +// order, emitting every line exactly once so a caller can rewrite the file in a +// single pass. A rule line is parsed only inside *filter, so an input rule +// sitting in the nat table is never read as a filter rule. +// +// A LOG line pairs with the action line PHYSICALLY under it and nothing else, as +// in the iptables backend: iptables writes a logged rule as two lines, and a +// pair separated by any other line is not one rule. Pairing across such a line +// would report a logged rule no removal could ever locate. An orphan LOG line +// begins no logical rule; its group still streams through with rule left nil. +// +// A nil fd scans as an empty file. An error from fn stops the scan. +func (f *UFW) scanBeforeGroups(fd *os.File, family Family, fn func(g ufwGroup) error) error { + if fd == nil { + return nil + } + table := "" + // held buffers a parsed LOG line until the following line decides whether it + // is that line's action partner; emitHeld flushes it as the orphan it turned + // out to be, with its rule cleared. + var held *ufwGroup + emitHeld := func() error { + if held == nil { + return nil + } + g := *held + held = nil + g.rule = nil + return fn(g) + } + + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + raw := scanner.Text() + line := strings.TrimSpace(raw) + + // A table header opens its table and a COMMIT closes it. Both end any + // pending LOG pairing, since a partner must sit on the very next line. + if strings.HasPrefix(line, "*") || line == "COMMIT" { + if err := emitHeld(); err != nil { + return err + } + g := ufwGroup{raw: []string{raw}, table: table} + if line == "COMMIT" { + g.commit = true + table = "" + } else { + table = strings.TrimPrefix(line, "*") + g.table = table + } + if err := fn(g); err != nil { + return err + } + continue + } + + g := ufwGroup{raw: []string{raw}, table: table} + // Only the filter table models a rule; every other line passes through and + // breaks any pending pairing. + if rule, ok := f.parseIPTablesLine(line, family); ok && table == "filter" { + // Fold a held LOG line together with the action line directly under it + // into the one logged rule they encode. + if held != nil && logPartner(held.rule, rule) { + pair := *held + held = nil + pair.raw = append(pair.raw, raw) + pair.rule = mergeLogPair(pair.rule, rule) + if err := fn(pair); err != nil { + return err + } + continue + } + // Not a partner, so any held LOG line is an orphan; flush it before this + // line. + if err := emitHeld(); err != nil { + return err + } + // Buffer a bare LOG line (Log set, no terminal action) against the next + // line; every other rule is complete on its own. + if rule.Action == ActionInvalid && rule.Log { + g.rule = rule + held = &g + continue + } + g.rule = rule + } + + if err := emitHeld(); err != nil { + return err + } + if err := fn(g); err != nil { + return err + } + } + if err := scanner.Err(); err != nil { + return err + } + return emitHeld() +} + +// ParseIPTablesRules parses a ufw before/after rules file, which is in +// iptables-restore format using ufw's own chains. Each `-A <chain> ...` line in +// the filter table on an input/output/forward chain is reparsed with the +// iptables rulespec parser, a logged rule's LOG and action lines folding back +// into one rule; lines whose match or action this model cannot represent are +// skipped by the scan. +func (f *UFW) ParseIPTablesRules(filePath string, family Family) (rules []*Rule, err error) { + fd, err := os.Open(filePath) + if err != nil { + // A missing iptables rules file simply contributes no rules. + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = fd.Close() }() + + err = f.scanBeforeGroups(fd, family, func(g ufwGroup) error { + if g.rule != nil { + rules = append(rules, g.rule) + } + return nil + }) + if err != nil { + return nil, err + } + return rules, nil +} + +// parseAddr validates a ufw tuple address token (an IP or CIDR) and returns +// it, blanking a zero-network ("0.0.0.0/0" or "::/0") to the empty "any" +// address. +func (f *UFW) parseAddr(tok string) (string, error) { + _, network, err := net.ParseCIDR(tok) + ip := net.ParseIP(tok) + if err != nil && ip == nil { + return "", fmt.Errorf("invalid address parameter %q", tok) + } + if network != nil { + if ones, _ := network.Mask.Size(); ones == 0 { + return "", nil + } + } + return tok, nil +} + +// UnmarshalRule decodes a ufw tuple into a firewall rule. A ufw tuple carries six core fields +// (action, proto, dport, dst, sport, src), an optional pair of application-name +// fields (dapp, sapp), and a trailing direction/interface field, so a tuple ufw +// itself writes has 7 tokens, or 9 for the application-profile form; a bare +// 6-token tuple (direction/interface omitted, defaulting to inbound) is also +// accepted here for tolerance, though ufw does not generate one. An +// application-profile rule's six core fields already carry the concrete +// proto/port the profile expands to (e.g. `allow tcp 80 ... Apache - in`) — dapp +// and sapp are just the profile's name, informational labels this library has no +// field for, so they are parsed to locate the trailing direction field and then +// discarded; the rule decodes exactly like an ordinary 7-token tuple otherwise. +// An 8-token tuple is ufw's old storage format (six core fields plus dapp/sapp +// with no direction field), which this library does not model; it is rejected so +// the row stays opaque and is preserved verbatim. +func (f *UFW) UnmarshalRule(tuple string, family Family) (r *Rule, err error) { + r = &Rule{ + Family: family, + } + tokens := strings.Split(tuple, " ") + + // A `route:` prefix on the action marks a forward-chain (routed) rule. Strip it + // and flag the direction; a route rule's interfaces are read from the trailing + // field(s) below rather than fixing a single in/out direction. + forward := false + if strings.HasPrefix(tokens[0], "route:") { + forward = true + tokens[0] = strings.TrimPrefix(tokens[0], "route:") + r.Direction = DirForward + } + + n := len(tokens) + // A dual-interface route rule stays at seven tokens — ufw joins both + // interfaces into one trailing "!"-separated token (`in_eth0!out_eth1`) — so + // eight tokens only occurs in ufw's unmodeled old format. + if n < 6 || n > 9 || n == 8 { + return nil, fmt.Errorf("invalid rule length") + } + + // Check action. ufw encodes the action field as a base action with an optional + // `_<logtype>` suffix (`log` or `log-all`) — e.g. `allow_log`, `limit_log`. + // Split that off so a logged or rate-limited tuple is read rather than dropped + // as an "unsupported action". + action := tokens[0] + if base, logtype, hasLog := strings.Cut(action, "_"); hasLog { + if logtype != "log" && logtype != "log-all" { + return nil, fmt.Errorf("unsupported action: %s", tokens[0]) + } + action = base + r.Log = true + } + switch action { + case "allow": + r.Action = Accept + case "deny": + r.Action = Drop + case "reject": + r.Action = Reject + case "limit": + // ufw's `limit` is an accept that rate-limits new connections (its + // built-in policy blocks a source with 6 or more connections in 30 + // seconds, i.e. 6 per 30s). Represent it as an accept carrying that rate + // so the rule is reported by GetRules and stays distinct from a plain + // allow; the window is expressed per-minute (12/minute == 6/30s) as the + // model has no sub-minute unit. + r.Action = Accept + rate := f.nativeLimit() + r.RateLimit = &rate + default: + return nil, fmt.Errorf("unsupported action: %s", tokens[0]) + } + + // The trailing token after the six core fields carries the direction and any + // interface binding: `in`, `out`, an interface-bound `in_eth0`/`out_eth0`, or + // — on a route rule binding both interfaces — ufw's "!"-joined pair + // `in_eth0!out_eth1`, still one token. An application-profile tuple (n==9) + // carries the dapp/sapp labels in tokens 6-7 and the direction in token 8. A + // 6-token tuple omits the field and defaults to inbound. For a route rule the + // direction stays forward and the interfaces populate InInterface/ + // OutInterface; for an ordinary rule the token fixes the in/out direction and + // its interface. + var dirTok string + switch n { + case 7: + dirTok = tokens[6] + case 9: + dirTok = tokens[8] + } + if dirTok != "" { + parts := strings.SplitN(dirTok, "!", 2) + if len(parts) == 2 && !forward { + // Only a route rule carries both interfaces in one token. + return nil, fmt.Errorf("unsupported direction: %s", dirTok) + } + for _, part := range parts { + name, iface, hasIface := strings.Cut(part, "_") + switch name { + case "in": + if !forward { + r.Direction = DirInput + } + if hasIface { + r.InInterface = iface + } + case "out": + if !forward { + r.Direction = DirOutput + } + if hasIface { + r.OutInterface = iface + } + default: + return nil, fmt.Errorf("unsupported direction: %s", dirTok) + } + } + } + + // Resolve the protocol token. ufw's `any` is deferred: on a ported tuple it means + // tcp+udp together (TCPUDP), on a portless tuple it means every IP protocol + // (ProtocolAny), so the ports must be parsed first to tell them apart. A non-`any` + // token must name a known protocol; GetProtocol returns ProtocolAny for an unknown + // value, so an unknown token that is not literally `any` is rejected. + isAny := strings.EqualFold(tokens[1], "any") + r.Proto = GetProtocol(tokens[1]) + if r.Proto == ProtocolAny && !isAny { + return nil, fmt.Errorf("invalid protocol parameter") + } + + // Parse destination port(s): a single port, a colon range, or a comma list. + if !strings.EqualFold(tokens[2], "any") { + specs, perr := ParsePortRanges(tokens[2], ",") + if perr != nil { + return nil, fmt.Errorf("the port argument %s is invalid", tokens[2]) + } + portSpecsToRule(r, specs) + } + + // Parse destination address. + r.Destination, err = f.parseAddr(tokens[3]) + if err != nil { + return nil, err + } + + // Parse source port(s). + if !strings.EqualFold(tokens[4], "any") { + specs, perr := ParsePortRanges(tokens[4], ",") + if perr != nil { + return nil, fmt.Errorf("the source port argument %s is invalid", tokens[4]) + } + sourcePortSpecsToRule(r, specs) + } + + // Parse source address. + r.Source, err = f.parseAddr(tokens[5]) + if err != nil { + return nil, err + } + + // Resolve ufw's `any` protocol now that the ports are known: a ported `any` tuple + // matches tcp+udp together (TCPUDP), a portless one matches every IP protocol + // (ProtocolAny, the value GetProtocol already assigned). TCPUDP carries ports, + // ProtocolAny does not, so this keeps the two ufw meanings of `any` distinct. + if isAny && (r.HasPorts() || r.HasSourcePorts()) { + r.Proto = TCPUDP + } + return +} + +// parseTupleRows scans a ufw rules file and returns one entry per `### tuple ###` +// line, in file order: the parsed rule, or nil for a non-empty tuple this backend +// does not model (one that fails to parse). ufw counts every tuple in its own +// numbered list, so keeping such rows as nil lets callers map a representable rule +// to its true physical position. Only a tuple whose body is empty after stripping +// the comment is dropped without occupying a slot. +func (f *UFW) parseTupleRows(filePath string, family Family) ([]*Rule, error) { + fd, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer func() { _ = fd.Close() }() + + var rows []*Rule + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + // Get the line. + line := scanner.Text() + + // Ignore non-tuple lines. + tuplePrefix := "### tuple ### " + if !strings.HasPrefix(line, tuplePrefix) { + continue + } + line = strings.TrimPrefix(line, tuplePrefix) + + // Remove comments. + ci := strings.IndexByte(line, '#') + if ci >= 0 { + line = line[:ci] + } + // A trailing ` comment=<hex>` carries the ufw rule comment, hex-encoded + // UTF-8. Capture and decode it, then strip it before parsing the tuple. + var comment string + if ci = strings.LastIndex(line, " comment="); ci >= 0 { + hexVal := strings.TrimSpace(line[ci+len(" comment="):]) + if b, derr := hex.DecodeString(hexVal); derr == nil { + comment = string(b) + } + line = line[:ci] + } + + // Trim spaces. + line = strings.TrimSpace(line) + + // Ignore zero lines. + if len(line) == 0 { + continue + } + + // Parse rule. A tuple this backend cannot model (ufw's old 8-field + // format, an application profile it cannot resolve, a malformed line) is + // kept as a nil row so it still occupies a physical position. + rule, err := f.UnmarshalRule(line, family) + if err != nil { + rows = append(rows, nil) + continue + } + // Strip the prefix so only the user-facing comment surfaces, and flag + // whether the prefix marked this as one of our rules. + text, hasPrefix := prefixedComment(f.rulePrefix, comment) + rule.Comment = text + rule.HasPrefix = hasPrefix + rows = append(rows, rule) + } + if serr := scanner.Err(); serr != nil { + return nil, serr + } + return rows, nil +} + +// ParseRules reads a ufw rules file and returns the rules it models, in file order. +func (f *UFW) ParseRules(filePath string, family Family) (rules []*Rule, err error) { + rows, err := f.parseTupleRows(filePath, family) + if err != nil { + return nil, err + } + for _, r := range rows { + if r != nil { + rules = append(rules, r) + } + } + return rules, nil +} + +// --- live counters ----------------------------------------------------------- + +// nativeLimit returns the rate ufw's built-in `limit` action enforces: 6 new +// connections per 30s, expressed per minute because the model has no sub-minute +// unit. It is the signature UnmarshalRule decodes a `limit` tuple into, and the +// one liveRules restores onto a row that jumps to ufw's limit-accept chain. +func (f *UFW) nativeLimit() RateLimit { + return RateLimit{Rate: 12, Unit: PerMinute, Burst: 6} +} + +// internalJump classifies a jump target that names one of ufw's own internal +// chains, so a live row can be read back as the rule that produced it. ufw +// expands a logged rule into a jump into its logging chain followed by the +// action line, and its built-in `limit` action into a three-line sequence +// ending in a jump to the limit-accept chain. Both the IPv4 (`ufw-`) and IPv6 +// (`ufw6-`) chain names are accepted. An empty string means the target is not +// one of ufw's internal chains. +func (f *UFW) internalJump(target string) string { + rest, ok := strings.CutPrefix(target, "ufw-") + if !ok { + if rest, ok = strings.CutPrefix(target, "ufw6-"); !ok { + return "" + } + } + switch rest { + case "user-logging-input", "user-logging-output", "user-logging-forward": + return "logging" + case "user-limit": + return "limit" + case "user-limit-accept": + return "limit-accept" + } + return "" +} + +// parseLiveRules decodes counter-annotated `iptables-save -c` output into the +// rules ufw's own chains hold. A live row is the expansion ufw writes for a rule +// rather than the rule itself, so this reverses the two expansions its rules +// files apply — the jump into the logging chain that precedes a logged rule's +// action line, and the three-line sequence its `limit` action becomes — leaving +// a row that lines up with the tuple or before.rules line that produced it. +func (f *UFW) parseLiveRules(out []string, fam Family) []*Rule { + // A pending logging jump only carries into the row directly beneath it, + // within the same chain. + logged := false + return decodeLiveRows(out, func(row liveRow) (*Rule, bool) { + if row.newChain { + logged = false + } + dir, ok := f.ipTablesChain(row.chain) + if !ok { + return nil, false + } + + // Undo ufw's expansions, so what is left is the row carrying the rule. + target := jumpTarget(row.fields) + if target == "" { + // A row with no jump models nothing: it is the `-m recent --set` + // accounting row opening a `limit` rule. Skipping it here rather than + // letting the parse reject it keeps a pending logging jump alive, since + // a `limit_log` rule's log half sits above this row. + return nil, false + } + kind := f.internalJump(target) + switch kind { + case "logging": + // The log half of a logged rule; the row beneath it holds the action. + logged = true + return nil, false + case "limit": + // The over-limit path of a `limit` rule, which stands for no rule of + // its own; the accept row below it is the one the tuple models. + return nil, false + case "limit-accept": + // ufw accepts a rate-limited rule through its own chain. Restore the + // plain accept here and the rate below, which together are what the + // tuple carries. + row.line = strings.Replace(row.line, "-j "+target, "-j ACCEPT", 1) + } + + rule, ok := parseLiveRow(row, dir, fam) + wasLogged := logged + logged = false + if !ok { + return nil, false + } + if kind == "limit-accept" { + rate := f.nativeLimit() + rule.RateLimit = &rate + } + rule.Log = rule.Log || wasLogged + return rule, true + }) +} + +// mergeLiveCounters copies the kernel's packet/byte counters onto the rules read +// from ufw's rules files, matching by rule identity. +func (f *UFW) mergeLiveCounters(ctx context.Context, rules []*Rule, fam Family) { + // ufw keys each family into its own files, so a rule always names one; only + // this family's rules can match this family's ruleset. + targets := countableRules(rules, fam) + if len(targets) == 0 { + return + } + applyLiveCounters(targets, f.parseLiveRules(liveSaveLines(ctx, fam), fam)) +} + +// GetRules returns the existing filter rules from the zone: ufw's own numbered +// tuples from user.rules and user6.rules, then the raw iptables rules from the +// before.rules files, which carry the matches the tuple format cannot express. +func (f *UFW) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + // Parse IPv4 user rules. + tupleRules, err := f.ParseRules(UFWIPv4, IPv4) + if err != nil { + return nil, err + } + + // Parse IPv6 user rules. + v6Rules, err := f.ParseRules(UFWIPv6, IPv6) + if err != nil { + return nil, err + } + tupleRules = append(tupleRules, v6Rules...) + + // Number the tuple rules as one ordered list: `ufw insert` positions within a + // single numbered list spanning both families. The raw before.rules entries read + // below sit outside that list, so they keep Number 0. + numberSequential(tupleRules) + rules = append(rules, tupleRules...) + + // Parse the before.rules iptables files, which carry ICMP and other rules the + // user-rule tuple format cannot express. Only the before.rules files are read: + // this backend writes and removes raw rules exclusively there (see + // iptablesFilesFor), so reading after.rules too would surface rules it cannot + // remove — Restore then re-added them into before.rules, duplicating them. + iptablesRules, ferr := f.ParseIPTablesRules(UFWBefore, IPv4) + if ferr != nil { + return nil, ferr + } + rules = append(rules, iptablesRules...) + ip6tablesRules, ferr := f.ParseIPTablesRules(UFWBefore6, IPv6) + if ferr != nil { + return nil, ferr + } + rules = append(rules, ip6tablesRules...) + + // ufw's rules files carry no packet/byte counters — the kernel does — so merge + // them from the live ruleset (RuleCounters). + f.mergeLiveCounters(ctx, rules, IPv4) + f.mergeLiveCounters(ctx, rules, IPv6) + return +} + +// zeroNet returns the zero-network ("any") CIDR for a family, defaulting to +// the IPv4 form when the family is unspecified. +func (f *UFW) zeroNet(fam Family) string { + if fam == IPv6 { + return "::/0" + } + return "0.0.0.0/0" +} + +// anyAddr returns the address literal used to stand in for an unspecified +// endpoint when ufw's grammar forces one. A concrete family uses its +// zero-network CIDR; a family-agnostic rule uses the literal "any" so ufw +// installs both the IPv4 and IPv6 rule — a zero-network CIDR (which is +// family-specific) would silently pin the rule to a single family and break the +// round-trip back to FamilyAny. +func (f *UFW) anyAddr(fam Family) string { + if fam == FamilyAny { + return "any" + } + return f.zeroNet(fam) +} + +// isNativeLimit reports whether r is expressible as ufw's built-in `limit` +// action: an accept carrying exactly ufw's fixed rate (6 connections per 30s, +// modeled as 12/minute burst 6) and no other modifier the tuple form cannot +// hold. UnmarshalRule decodes a `limit` tuple into this exact shape, so it is the +// signature that round-trips through the CLI rather than the before.rules files. +func (f *UFW) isNativeLimit(r *Rule) bool { + // Logging is allowed: `ufw limit log ...` writes a `limit_log` tuple, which + // UnmarshalRule decodes back into this same shape with Log set. Excluding + // logged limits would route such a rule to the before.rules files even though + // it lives in user.rules, leaving it unremovable there and duplicating it on + // Restore. A custom LogPrefix still cannot be expressed in a tuple, so a limit + // carrying one stays false here and is routed to before.rules (which can). + return r.Action == Accept && r.ConnLimit == nil && r.State == 0 && r.LogPrefix == "" && + r.RateLimit != nil && *r.RateLimit == f.nativeLimit() +} + +// protoNeedsRaw reports whether a protocol cannot be expressed through ufw's +// CLI/tuple format and must instead be written as a raw before.rules rule. ufw's +// supported_protocols list (src/util.py) carries tcp, udp, esp, ah and gre +// natively, so only ICMP/ICMPv6 and SCTP — which ufw does not accept — go through +// the iptables rules files. +func (f *UFW) protoNeedsRaw(p Protocol) bool { + return p.IsICMP() || p == SCTP +} + +// MarshalRule encodes a firewall rule into a ufw rulespec. It validates nothing: +// the entry points run Rule.validate and then route every shape the tuple cannot +// carry elsewhere — ICMP/SCTP, a state match, a custom log prefix, a connection +// limit, a non-native rate limit and a negated or ipset address go to the +// before.rules files (needsIPTablesRules), while a portless or multiport tcp+udp +// match fans out into concrete tcp and udp rules (tcpudpNeedsExpand). +func (f *UFW) MarshalRule(r *Rule) string { + // Start out with the action. ufw's built-in rate limit is its own `limit` verb + // (an accept), so a native-limit rule emits that rather than `allow`; + // UnmarshalRule reads it back into the same rate. + action := "allow" + if f.isNativeLimit(r) { + action = "limit" + } else if r.Action == Drop { + action = "deny" + } else if r.Action == Reject { + action = "reject" + } + parts := []string{action} + + // Direction and interface binding. A forward rule is emitted as a route rule + // carrying an `in on <in>` and/or `out on <out>` clause (the `route` keyword and + // the command verb are prepended by the caller in ruleArgs); ufw rejects a + // bare direction on a route rule, so none is emitted. An ordinary rule carries + // its single direction and, when set, its interface. + hasIface := r.InInterface != "" || r.OutInterface != "" + if r.IsForward() { + if r.InInterface != "" { + parts = append(parts, "in", "on", r.InInterface) + } + if r.OutInterface != "" { + parts = append(parts, "out", "on", r.OutInterface) + } + } else { + dir := "in" + iface := r.InInterface + if r.IsOutput() { + dir = "out" + iface = r.OutInterface + } + parts = append(parts, dir) + if iface != "" { + parts = append(parts, "on", iface) + } + } + + // Per-rule logging: ufw's `log` keyword follows the direction and any interface + // clause (a non-interface rule has its direction stripped by ufw before the + // keyword is read, so `allow in log ...` and `allow in on eth0 log ...` are both + // valid). ufw uses its own log prefixes; a rule carrying a custom LogPrefix is + // routed to before.rules and never reaches here. + if r.Log { + parts = append(parts, "log") + } + + // If family is not defined, but a source or destination address is, find out the + // family. It is held in a local rather than written back so the caller's rule is + // never mutated. An address ufw itself will reject leaves the rule + // family-agnostic rather than failing here; ufw's own validation is the arbiter + // of the address. + family := r.impliedFamily() + + // A source port needs a `from ... port` clause, and a destination port that + // follows a from clause cannot use the bare short form, so synthesize + // zero-network addresses where needed to keep the grammar well formed. + srcAddr := r.Source + if r.HasSourcePorts() && srcAddr == "" { + srcAddr = f.anyAddr(family) + } + // Ensure the destination for family-specific rules has a zero IP address to + // allow using the `to/from` definition. + dstAddr := r.Destination + if dstAddr == "" && family != FamilyAny { + dstAddr = f.zeroNet(family) + } + if r.HasPorts() && dstAddr == "" && srcAddr != "" { + dstAddr = f.anyAddr(family) + } + // ufw's short port form (`22/tcp`) is rejected when the rule also binds an + // interface (`on eth0`); that combination needs the full `to <any> port ... + // proto ...` grammar. Synthesize a destination so the full form is emitted, + // using the literal `any` for a family-agnostic rule so ufw still covers both + // IPv4 and IPv6. + if r.HasPorts() && dstAddr == "" && hasIface { + dstAddr = f.anyAddr(family) + } + // A portless, address-less rule has no short form to hold its protocol, so give + // it an `any` destination and let the `proto` clause below carry the protocol. + // This covers a portless native protocol (gre, esp, ah) and, crucially, a bare + // tcp/udp match ("allow all TCP inbound"): without the synthesized destination + // the proto clause never fires and ufw is handed a bare `allow in`, which it + // rejects ("Invalid interface clause"). A true match-all rule (ProtocolAny, no + // match at all) likewise becomes `... to any`, the only form ufw accepts for it. + if !r.HasPorts() && !r.HasSourcePorts() && dstAddr == "" && srcAddr == "" { + dstAddr = f.anyAddr(family) + } + + // Add protocol only when an IP address (or a source port, which forces a + // from clause) is present; a bare destination-port rule carries its protocol + // in the short form below. A proto clause is emitted only for a concrete + // protocol: ufw's tuple has no `tcpudp` keyword — it expresses tcp+udp by OMITTING + // the protocol (its `any` protocol on a ported rule) — so TCPUDP emits no proto + // clause, exactly as ProtocolAny does. + if r.Proto != ProtocolAny && r.Proto != TCPUDP && (dstAddr != "" || srcAddr != "") { + parts = append(parts, "proto", r.Proto.String()) + } + + // Add source and its port(s). + if srcAddr != "" { + parts = append(parts, "from", srcAddr) + if r.HasSourcePorts() { + parts = append(parts, "port", iptMultiportValue(r.SourcePortSpecs())) + } + } + + // Add destination and its port(s). ufw accepts a comma list and colon ranges, + // the same form as iptables multiport. + if dstAddr != "" { + parts = append(parts, "to", dstAddr) + if r.HasPorts() { + parts = append(parts, "port", iptMultiportValue(r.PortSpecs())) + } + } + + // If destination port(s) defined and no address on either side, add them in + // ufw's short form. The protocol rides along as `port/proto` for a concrete + // transport, or bare `port` for ufw's `any` protocol — which on a ported rule + // means tcp+udp, i.e. TCPUDP. A ProtocolAny port has no port-carrying transport + // and is rejected by Rule.validate, so the bare form here is reached only by TCPUDP. + if r.HasPorts() && dstAddr == "" && srcAddr == "" { + val := iptMultiportValue(r.PortSpecs()) + if r.Proto == TCPUDP { + parts = append(parts, val) + } else { + parts = append(parts, fmt.Sprintf("%s/%s", val, r.Proto.String())) + } + } + + // Return the built parts joined with spaces. + return strings.Join(parts, " ") +} + +// commentFor returns the comment text ufw should tag a rule with: the configured +// prefix carried alongside the user-supplied comment (prefix + " " + comment), so +// rules this library creates stay identifiable. +func (f *UFW) commentFor(r *Rule) string { + return combineComment(f.rulePrefix, r.Comment) +} + +// rewriteToChain rewrites an iptables `-A INPUT/OUTPUT ...` line to use ufw's +// own before-chain names. The IPv6 rules file (before6.rules) declares its chains +// with the `ufw6-` prefix, so a rule bound there must use those names or +// ip6tables-restore rejects the file on `ufw reload`. +func (f *UFW) rewriteToChain(line string, family Family) (string, error) { + prefix := "ufw" + if family == IPv6 { + prefix = "ufw6" + } + if rest, ok := strings.CutPrefix(line, "-A INPUT "); ok { + return "-A " + prefix + "-before-input " + rest, nil + } + if rest, ok := strings.CutPrefix(line, "-A OUTPUT "); ok { + return "-A " + prefix + "-before-output " + rest, nil + } + if rest, ok := strings.CutPrefix(line, "-A FORWARD "); ok { + return "-A " + prefix + "-before-forward " + rest, nil + } + return "", fmt.Errorf("unexpected iptables rule form: %s", line) +} + +// marshalIPTablesLines encodes a rule as the before.rules line(s) for the given +// family, reusing the iptables marshaller and rewriting the chain to ufw's own +// input/output/forward chain. A logged rule yields a LOG line followed by its +// action line. +func (f *UFW) marshalIPTablesLines(r *Rule, family Family) ([]string, error) { + ipt := &IPTables{rulePrefix: f.rulePrefix} + // The borrowed encoder takes its own check: this rule reached here through + // ufw's entry points, not iptables'. + if err := ipt.validateRule(r); err != nil { + return nil, err + } + lines, err := ipt.marshalRuleLines(r) + if err != nil { + return nil, err + } + out := make([]string, 0, len(lines)) + for _, line := range lines { + rewritten, rerr := f.rewriteToChain(line, family) + if rerr != nil { + return nil, rerr + } + out = append(out, rewritten) + } + return out, nil +} + +// editIPTablesRulesFile inserts (or removes) a rule's line(s) in a before.rules +// file, streaming the original through a staged rewrite so every line the edit +// does not touch — comments, user formatting, other tables — is copied through +// verbatim and the file is replaced atomically with its mode and ownership +// intact. An add splices the rule in just above the *filter section's COMMIT; a +// removal drops every line the rule covers, a logged rule's LOG and action lines +// together. It reports whether the file was changed. +func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove bool) (bool, error) { + fd, err := os.Open(path) + if err != nil { + // Nothing to remove from a file that is not there. + if os.IsNotExist(err) && remove { + return false, nil + } + return false, err + } + defer func() { _ = fd.Close() }() + + // An add resolves its line(s) and the already-present check before anything is + // staged: a duplicate add must leave the file untouched, and the rewrite pass + // below cannot look past the COMMIT it splices at. + var specs []string + if !remove { + present := false + if err := f.scanBeforeGroups(fd, family, func(g ufwGroup) error { + if g.rule != nil && g.rule.EqualBase(r, true) { + present = true + } + return nil + }); err != nil { + return false, err + } + if present { + return false, nil + } + if specs, err = f.marshalIPTablesLines(r, family); err != nil { + return false, err + } + if _, err := fd.Seek(0, io.SeekStart); err != nil { + return false, err + } + } + + af, err := newAtomicFile(path, 0640) + if err != nil { + return false, err + } + defer af.Abort() + write := func(g ufwGroup) error { + for _, l := range g.raw { + _, _ = fmt.Fprintln(af, l) + } + return nil + } + + changed := false + err = f.scanBeforeGroups(fd, family, func(g ufwGroup) error { + if remove { + // Drop every group the target covers, not only the first, so a + // before.rules file holding the rule twice comes back clean in one pass. + // The target is already one concrete family/transport/direction cell — + // RemoveRule fanned out the merged axes — and the file itself pins the + // family. A foreign LOG line adjacent to the target is its own group and + // so survives. + if g.rule != nil && g.rule.EqualBase(r, true) { + changed = true + return nil + } + return write(g) + } + // Anchor the add on the *filter section's COMMIT, not the file's first one: + // the canonical ufw NAT setup adds a *nat block above *filter, and a filter + // rule spliced into that block references an undeclared chain, failing the + // whole reload at iptables-restore. + if !changed && g.commit && g.table == "filter" { + for _, l := range specs { + _, _ = fmt.Fprintln(af, l) + } + changed = true + } + return write(g) + }) + if err != nil { + return false, err + } + if !remove && !changed { + return false, fmt.Errorf("no *filter COMMIT line found in %s", path) + } + if !changed { + return false, nil + } + if err := af.Commit(); err != nil { + return false, fmt.Errorf("failed to move new firewall rules into place: %s", err) + } + return true, nil +} + +// iptablesFilesFor returns the before.rules file(s) a rule applies to. An ICMP +// protocol pins the family; a family-agnostic rule (e.g. a bare state match) +// touches both the IPv4 and IPv6 files. +func (f *UFW) iptablesFilesFor(r *Rule) []string { + switch r.impliedFamily() { + case IPv4: + return []string{UFWBefore} + case IPv6: + return []string{UFWBefore6} + default: + return []string{UFWBefore, UFWBefore6} + } +} + +// editIPTablesRules applies an add/remove across every before.rules file the rule +// touches, recording whether a reload is needed. +func (f *UFW) editIPTablesRules(r *Rule, remove bool) error { + for _, path := range f.iptablesFilesFor(r) { + family := IPv4 + if path == UFWBefore6 { + family = IPv6 + } + changed, err := f.editIPTablesRulesFile(path, r, family, remove) + if err != nil { + return err + } + if changed { + f.iptablesRulesChanged = true + } + } + return nil +} + +// needsIPTablesRules reports whether a rule must be written as raw iptables +// rules rather than through ufw's command line. The ufw CLI and its user.rules +// tuple format cannot express ICMP/SCTP, a connection-state match, a custom log +// prefix or a rate/connection limit, but the before.rules files can. Plain +// logging (no custom prefix) is expressed natively with ufw's `log` keyword, so +// it stays on the CLI path. +func (f *UFW) needsIPTablesRules(r *Rule) bool { + if f.protoNeedsRaw(r.Proto) || r.State != 0 || (r.Log && r.LogPrefix != "") || r.ConnLimit != nil { + return true + } + // ufw's tuple format takes only addresses in from/to; an ipset reference is + // written as a raw before.rules rule (`-m set --match-set`) instead. + if isSetRef(r.Source) || isSetRef(r.Destination) { + return true + } + // ufw's tuple grammar has no address negation, but before.rules can express it + // as `iptables ! -s/-d`, so a negated plain address routes there rather than + // being rejected. (A negated ipset reference is already covered above.) + if neg, _ := splitAddrNeg(r.Source); neg { + return true + } + if neg, _ := splitAddrNeg(r.Destination); neg { + return true + } + // ufw's built-in `limit` action is expressed through the CLI/user.rules, so a + // rule carrying exactly that rate stays on the tuple path; any other rate + // limit can only be written as raw iptables in the before.rules files. + if r.RateLimit != nil && !f.isNativeLimit(r) { + return true + } + return false +} + +// ruleArgs builds the argument list for a ufw rule command: the optional +// command verb tokens (e.g. {"prepend"}, {"insert", "3"}, {"delete"}, or none for +// a plain tail append) followed by the marshaled rule spec split into tokens. A +// forward rule is a ufw route rule, so the `route` keyword precedes the verb +// (`ufw route prepend allow in on eth0 ...`). +func (f *UFW) ruleArgs(r *Rule, verb []string, spec string) []string { + tokens := strings.Split(spec, " ") + args := make([]string, 0, 1+len(verb)+len(tokens)) + if r.IsForward() { + args = append(args, "route") + } + args = append(args, verb...) + args = append(args, tokens...) + return args +} + +// tcpudpNeedsExpand reports whether a TCPUDP rule cannot be written as a single +// native ufw tuple and must be fanned out into concrete tcp+udp rows. ufw carries +// tcp+udp in one tuple only through its `any` protocol on a ported CLI rule: a +// portless TCPUDP would become ufw's every-protocol match, a multiport TCPUDP is +// rejected by ufw itself ("Must specify 'tcp' or 'udp' with multiple ports"), and a +// TCPUDP rule routed to the before.rules raw path (state, icmp, a non-native limit, +// a negated/ipset address) must reach the iptables marshaller as a concrete +// transport since that path has no both-transports form either. Any of those splits +// into a tcp row and a udp row before the rule is written or removed, mirroring the +// DirAny fan-out. +func (f *UFW) tcpudpNeedsExpand(r *Rule) bool { + if r.Proto != TCPUDP { + return false + } + if f.needsIPTablesRules(r) { + return true + } + // Native only for a single-port `any` tuple: it must carry a port (a portless + // `any` matches every protocol) and match single ports only. + if !r.HasPorts() && !r.HasSourcePorts() { + return true + } + return r.HasPortSet() || r.HasSourcePortSet() +} + +// AddRule adds a filter rule to the zone. +func (f *UFW) AddRule(ctx context.Context, zoneName string, r *Rule) error { + // A DirAny rule fans out into an inbound tuple plus its role-swapped outbound + // tuple; add each concrete-direction half (either may route to before.rules). + if r.Direction == DirAny { + for _, sub := range expandDirections(r) { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // A TCPUDP rule ufw cannot hold in a single native `any`-proto tuple is fanned out + // into a concrete tcp tuple and a udp tuple, the same way a DirAny rule fans out by + // direction. A native single-port TCPUDP falls through to the CLI path below, where + // MarshalRule emits ufw's bare/`any` short form for it. + if f.tcpudpNeedsExpand(r) { + for _, sub := range expandProtocols(r) { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // Verify the rule is valid for ufw before routing it: the tuple path and the + // before.rules path marshal through different encoders, so neither sees every + // rule. + if err := r.validate(); err != nil { + return err + } + + // ICMP, connection-state, logging and rate/connection-limit rules are not + // expressible through the ufw CLI, so they are written to the iptables-based + // before.rules files instead, with a set-referencing rule pinned to its + // set's family first. + if f.needsIPTablesRules(r) { + r, err := resolveSetRefRule(r, f.setHelper().setRefFamily) + if err != nil { + return err + } + return f.editIPTablesRules(r, false) + } + + args := f.ruleArgs(r, []string{"prepend"}, f.MarshalRule(r)) + // Attach a comment: the prefix tag and any user-supplied Comment are combined + // (prefix first, see combineComment) so the rule is identifiable as ours and + // the user text still surfaces. The comment is only added on insert; ufw + // matches deletes on the rule without it. + if c := f.commentFor(r); c != "" { + args = append(args, "comment", c) + } + _, err := runCommand(ctx, "ufw", args...) + return err +} + +// appendRule adds a rule at the end of ufw's numbered list with a plain +// `ufw <rule>` (ufw appends a non-inserted rule). It mirrors AddRule but does not +// use `ufw prepend`, so callers that need a tail append — InsertRule past the end, +// and MoveRule to the end — get end placement rather than front placement. Its +// only caller, InsertRule, already diverts raw rules to editIPTablesRules before +// reaching here, so r is always a native ufw rule at this point. +func (f *UFW) appendRule(ctx context.Context, r *Rule) error { + args := f.ruleArgs(r, nil, f.MarshalRule(r)) + if c := f.commentFor(r); c != "" { + args = append(args, "comment", c) + } + _, err := runCommand(ctx, "ufw", args...) + return err +} + +// nativeInsertPositionFromRows maps a 1-based logical position to ufw's 1-based +// native insert position, given the physical tuple rows in ufw's own order. A nil +// row is a tuple ufw counts in its numbered list but this backend does not model +// (an old-format or otherwise unparseable tuple); it still occupies a physical +// slot, so such a row preceding the anchor shifts the native position instead of +// being ignored — which would place the rule one slot too early per preceding +// unmodeled row and, for a first-match firewall, change enforcement. Every modeled tuple is its own rule, so with no +// un-representable rows this reduces to the plain physical position. +func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int { + var physPos []int + for i, r := range rows { + if r != nil { + physPos = append(physPos, i+1) + } + } + if position < 1 { + position = 1 + } + if position-1 >= len(physPos) { + // Past the last logical rule: point past the last physical tuple so ufw + // rejects the position and InsertRule falls back to a plain append. + return len(rows) + 1 + } + return physPos[position-1] +} + +// nativeInsertPosition maps a 1-based logical position (a rule's Number, as GetRules +// reports it) to the 1-based position ufw's own numbered list uses for `ufw insert`. +// The two index spaces differ because ufw's list also counts tuples this backend +// does not model. The tuple order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's +// native order, so the logical position's row in that list is its native position. A +// position past the last logical rule maps past the native count, which ufw rejects +// and InsertRule then handles as a plain append. +func (f *UFW) nativeInsertPosition(position int) (int, error) { + v4, err := f.parseTupleRows(UFWIPv4, IPv4) + if err != nil { + return 0, err + } + v6, err := f.parseTupleRows(UFWIPv6, IPv6) + if err != nil { + return 0, err + } + // Physical order is every IPv4 tuple then every IPv6 tuple — ufw's own numbered + // order. + return f.nativeInsertPositionFromRows(append(v4, v6...), position), nil +} + +// InsertRule inserts rule before the given 1-based position using `ufw insert`. +// position <= 0 is treated as 1; a position larger than the current rule count +// appends the rule (ufw itself rejects an out-of-range position, so that case +// falls back to a plain add). +func (f *UFW) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + // A DirAny rule occupies a tuple in each direction; insert each half at the + // requested position. + if r.Direction == DirAny { + for _, sub := range expandDirections(r) { + if err := f.InsertRule(ctx, zoneName, position, sub); err != nil { + return err + } + } + return nil + } + + // A TCPUDP rule with no single native tuple form is inserted as its concrete + // tcp+udp rows at the same position, mirroring AddRule's fan-out. + if f.tcpudpNeedsExpand(r) { + for _, sub := range expandProtocols(r) { + if err := f.InsertRule(ctx, zoneName, position, sub); err != nil { + return err + } + } + return nil + } + + // Verify the rule is valid for ufw before routing it, as in AddRule. + if err := r.validate(); err != nil { + return err + } + + if f.needsIPTablesRules(r) { + r, err := resolveSetRefRule(r, f.setHelper().setRefFamily) + if err != nil { + return err + } + return f.editIPTablesRules(r, false) + } + if position <= 0 { + position = 1 + } + + rule := f.MarshalRule(r) + + // position is a Number GetRules reported, which counts only the tuples this + // backend models, while `ufw insert` counts ufw's own numbered list — which + // also counts tuples it does not model. Map to that native position so an + // unmodeled tuple earlier in the list does not skew the insert. + native, err := f.nativeInsertPosition(position) + if err != nil { + return err + } + position = native + + args := f.ruleArgs(r, []string{"insert", strconv.Itoa(position)}, rule) + if c := f.commentFor(r); c != "" { + args = append(args, "comment", c) + } + _, err = runCommand(ctx, "ufw", args...) + // ufw rejects a position past the end of its (per-family) numbered rule list. + // The interface contract asks to append there, and ufw's own validation is the + // only reliable measure of that list's length. Append with a plain `ufw <rule>` + // (which adds at the tail); AddRule instead uses `ufw prepend`, which would put + // the rule at the front rather than the end. + if err != nil && strings.Contains(err.Error(), "Invalid position") { + return f.appendRule(ctx, r) + } + return err +} + +// deleteNative marshals r into a ufw rulespec and removes it with `ufw delete`, +// treating an already-absent rule as a no-op (matching every other backend). It is +// the single-tuple removal primitive RemoveRule builds its protocol-axis handling on. +func (f *UFW) deleteNative(ctx context.Context, r *Rule) error { + args := f.ruleArgs(r, []string{"delete"}, f.MarshalRule(r)) + if _, err := runCommand(ctx, "ufw", args...); err != nil { + // ufw reports a missing rule as "Could not delete non-existent rule". + if strings.Contains(strings.ToLower(err.Error()), "could not delete") { + return nil + } + return err + } + return nil +} + +// storedNativeTCPUDP returns the single native `any`-proto tuple ufw currently holds +// that backs r — one stored tuple UnmarshalRule read back as TCPUDP (ufw's ported +// `any`) — or nil when none does. A TCPUDP target may instead be backed by a +// separately-added tcp tuple and udp tuple, which delete individually, so the actual +// backing has to be resolved before deleting. It matches on everything but the +// transport (EqualForRemoval). It backs RemoveRule's choice between deleting one +// native tuple (splitting it for a concrete-transport target) and deleting a +// concrete tcp/udp pair, the analog of apf reading its CPORTS list before removal. +func (f *UFW) storedNativeTCPUDP(r *Rule) (*Rule, error) { + v4, err := f.ParseRules(UFWIPv4, IPv4) + if err != nil { + return nil, err + } + v6, err := f.ParseRules(UFWIPv6, IPv6) + if err != nil { + return nil, err + } + for _, e := range append(v4, v6...) { + if e.Proto == TCPUDP && e.EqualForRemoval(r, true) { + return e, nil + } + } + return nil, nil +} + +// removeIPTablesRules sweeps the before.rules files for a removal target. The raw +// files carry one transport per line, so a both-transports target sweeps its +// concrete tcp and udp rows (expandProtocols is a no-op for anything else). +func (f *UFW) removeIPTablesRules(r *Rule) error { + for _, sub := range expandProtocols(r) { + if err := f.editIPTablesRules(sub, true); err != nil { + return err + } + } + return nil +} + +// RemoveRule removes a filter rule from the zone. +func (f *UFW) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + // A DirAny target removes both its inbound and outbound tuple. + if r.Direction == DirAny { + for _, sub := range expandDirections(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // A TCPUDP rule with no single native tuple form (a raw-path, portless or multiport + // both-transports match) is removed as its concrete tcp+udp rows, mirroring + // AddRule's fan-out. A native single-port TCPUDP falls through to the tuple-backing + // resolution below. + if f.tcpudpNeedsExpand(r) { + for _, sub := range expandProtocols(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // Verify the rule is valid for ufw before routing it, as in AddRule. + if err := r.validate(); err != nil { + return err + } + + // Sweep the before.rules files on every removal, not only for a rule that has + // to live there: a rule ufw could hold natively may still have been written by + // hand into before.rules, GetRules reads both stores, so skipping the raw files + // for a native target would leave a matching line behind and still reported. + if err := f.removeIPTablesRules(r); err != nil { + return err + } + if f.needsIPTablesRules(r) { + return nil + } + + // Protocol-axis removal. A TCPUDP rule may be backed by a single native + // `any`-proto tuple, by a separately-added tcp tuple + udp tuple, or — since + // ufw treats a differing protocol as a distinct rule — by both at once, so a + // removal sweeps every backing rather than stopping at the first. + if onProtocolAxis(r.Proto) { + native, err := f.storedNativeTCPUDP(r) + if err != nil { + return err + } + if native != nil { + // Delete the native `any` tuple via its bare/`any` form (a TCPUDP + // marshal of the target). + del := *r + del.Proto = TCPUDP + if err := f.deleteNative(ctx, &del); err != nil { + return err + } + } + // Delete the concrete-transport tuples: a TCPUDP target expands into + // tcp+udp, a concrete target deletes itself. deleteNative is a no-op for a + // tuple ufw does not hold. + for _, sub := range expandProtocols(r) { + if err := f.deleteNative(ctx, sub); err != nil { + return err + } + } + // When a concrete-transport target consumed a native both-transports + // tuple, re-add the surviving opposite transport so its coverage is kept — + // the protocol analog of splitting a dual-family row on removal. AddRule + // dedups, so an already-present concrete twin is not doubled. + if native != nil { + if s := splitDualRowProtocol(native, r); s != nil { + return f.AddRule(ctx, zoneName, s) + } + } + return nil + } + + return f.deleteNative(ctx, r) +} + +// MoveRule repositions an existing rule. ufw has no native move verb, so a move +// is a positional delete-then-insert: the rule is removed and re-inserted at the +// requested slot. It is therefore not atomic — if the re-insert fails the rule is +// left removed. A position larger than the rule count moves the rule to the end +// (via InsertRule's append fallback). +func (f *UFW) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + if err := f.RemoveRule(ctx, zoneName, r); err != nil { + return err + } + return f.InsertRule(ctx, zoneName, position, r) +} + +// natHelper returns an iptables backend scoped to ufw's before.rules files, so +// the iptables nat-table machinery (marshal/parse/edit) can be reused: ufw's +// before.rules is loaded through iptables-restore and takes a standard `*nat` +// table. Edits set iptablesRulesChanged so Reload runs `ufw reload`. +func (f *UFW) natHelper() *IPTables { + return &IPTables{rulePrefix: f.rulePrefix, IP4Path: UFWBefore, IP6Path: UFWBefore6} +} + +// GetNATRules returns the current NAT rules for the zone. +func (f *UFW) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) { + return f.natHelper().GetNATRules(ctx, zoneName) +} + +// AddNATRule adds a NAT rule to the zone. +func (f *UFW) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := f.natHelper().AddNATRule(ctx, zoneName, r); err != nil { + return err + } + f.iptablesRulesChanged = true + return nil +} + +// InsertNATRule positions a NAT rule within ufw's before.rules nat table, reusing +// the iptables helper's insert machinery. +func (f *UFW) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + if err := f.natHelper().InsertNATRule(ctx, zoneName, position, r); err != nil { + return err + } + f.iptablesRulesChanged = true + return nil +} + +// MoveNATRule repositions a NAT rule within ufw's before.rules nat table, reusing +// the iptables helper's move machinery. +func (f *UFW) MoveNATRule(ctx context.Context, zoneName string, r *NATRule, position int) error { + if err := f.natHelper().MoveNATRule(ctx, zoneName, r, position); err != nil { + return err + } + f.iptablesRulesChanged = true + return nil +} + +// RemoveNATRule removes a NAT rule from the zone. +func (f *UFW) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := f.natHelper().RemoveNATRule(ctx, zoneName, r); err != nil { + return err + } + f.iptablesRulesChanged = true + return nil +} + +// policyKey is the /etc/default/ufw key for a direction's default policy. +func (f *UFW) policyKey(d Direction) string { + switch d { + case DirOutput: + return "DEFAULT_OUTPUT_POLICY" + case DirForward: + return "DEFAULT_FORWARD_POLICY" + } + return "DEFAULT_INPUT_POLICY" +} + +// readPolicy reads /etc/default/ufw and returns the default policy for each +// direction. A direction whose key is absent is reported as ActionInvalid. +func (f *UFW) readPolicy() (*DefaultPolicy, error) { + fd, err := os.Open(UFWDefaults) + if err != nil { + return nil, err + } + defer func() { _ = fd.Close() }() + vals := map[string]string{} + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := scanner.Text() + if ci := strings.IndexByte(line, '#'); ci >= 0 { + line = line[:ci] + } + line = strings.TrimSpace(line) + key, val, found := strings.Cut(line, "=") + if !found { + continue + } + vals[strings.TrimSpace(key)] = trimQuotes(strings.TrimSpace(val)) + } + if err := scanner.Err(); err != nil { + return nil, err + } + policy := &DefaultPolicy{} + for _, d := range []Direction{DirInput, DirOutput, DirForward} { + v, ok := vals[f.policyKey(d)] + if !ok { + continue + } + // ufw stores the policy as a quoted ACCEPT/DROP/REJECT token. + if a, err := ParseAction(v); err == nil && a != ActionInvalid { + policy.set(d, a) + } + } + return policy, nil +} + +// GetDefaultPolicy returns the default filter policy for each direction. +func (f *UFW) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + return f.readPolicy() +} + +// policyValue renders an action as ufw's quoted policy token +// (DEFAULT_*_POLICY="ACCEPT"), matching how ufw itself writes the file. +func (f *UFW) policyValue(a Action) string { + switch a { + case Accept: + return `"ACCEPT"` + case Drop: + return `"DROP"` + case Reject: + return `"REJECT"` + } + return "" +} + +// writePolicy writes the default policy for each direction into +// /etc/default/ufw, updating an existing key in place and appending any that is +// absent. +func (f *UFW) writePolicy(policy *DefaultPolicy) error { + existing, err := os.ReadFile(UFWDefaults) + if err != nil { + return err + } + lines := strings.Split(string(existing), "\n") + written := map[Direction]bool{} + for i, line := range lines { + body := line + if ci := strings.IndexByte(body, '#'); ci >= 0 { + body = body[:ci] + } + key, _, found := strings.Cut(strings.TrimSpace(body), "=") + if !found { + continue + } + for _, d := range []Direction{DirInput, DirOutput, DirForward} { + if key != f.policyKey(d) || policy.get(d) == ActionInvalid { + continue + } + lines[i] = fmt.Sprintf("%s=%s", f.policyKey(d), f.policyValue(policy.get(d))) + written[d] = true + } + } + for _, d := range []Direction{DirInput, DirOutput, DirForward} { + if written[d] || policy.get(d) == ActionInvalid { + continue + } + lines = append(lines, fmt.Sprintf("%s=%s", f.policyKey(d), f.policyValue(policy.get(d)))) + } + return writeConfigFile(UFWDefaults, []byte(strings.Join(lines, "\n")), 0640) +} + +// SetDefaultPolicy sets the default filter policy for each direction. +func (f *UFW) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + if policy == nil { + return fmt.Errorf("policy cannot be nil") + } + // ufw supports accept, drop and reject as a default policy; a direction left + // ActionInvalid is skipped by writePolicy and left unchanged. + if err := f.writePolicy(policy); err != nil { + return err + } + f.iptablesRulesChanged = true + return nil +} + +// --- address sets (ipset) --------------------------------------------------- + +// setHelper returns the session's iptables backend, used to reach the +// ipset-backed address-set implementation. A manager built as a bare struct +// rather than through NewUFW gets one on first use, with no staging file +// detected, which leaves its sets live-only. +func (f *UFW) setHelper() *IPTables { + if f.sets == nil { + f.sets = &IPTables{rulePrefix: f.rulePrefix} + } + return f.sets +} + +// GetAddressSets returns all managed address sets. +func (f *UFW) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + return f.setHelper().GetAddressSets(ctx) +} + +// GetAddressSet returns the named address set. +func (f *UFW) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + return f.setHelper().GetAddressSet(ctx, name) +} + +// AddAddressSet creates an address set. +func (f *UFW) AddAddressSet(ctx context.Context, set *AddressSet) error { + return f.setHelper().AddAddressSet(ctx, set) +} + +// RemoveAddressSet removes the named address set. +func (f *UFW) RemoveAddressSet(ctx context.Context, name string) error { + return f.setHelper().RemoveAddressSet(ctx, name) +} + +// AddAddressSetEntry adds an entry to an address set. +func (f *UFW) AddAddressSetEntry(ctx context.Context, name, entry string) error { + return f.setHelper().AddAddressSetEntry(ctx, name, entry) +} + +// RemoveAddressSetEntry removes an entry from an address set. +func (f *UFW) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + return f.setHelper().RemoveAddressSetEntry(ctx, name, entry) +} + +// Backup captures the current filter and NAT rules managed by this backend. +func (f *UFW) Backup(ctx context.Context, zoneName string) (*Backup, error) { + rules, err := f.GetRules(ctx, zoneName) + if err != nil { + return nil, err + } + natRules, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return nil, err + } + // Backup captures the full filter and NAT rule state plus the default policy and + // managed ipsets; Restore rebuilds them, so every rule read is preserved and + // re-applied. + backup := &Backup{Rules: rules, NATRules: natRules} + if err := captureBackupState(ctx, f, zoneName, backup); err != nil { + return nil, err + } + return backup, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *UFW) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + + // Snapshot the actual current state and remove it, so Restore reconciles the + // live firewall to the backup rather than only re-touching the backup's own + // rules: a rule present now but absent from the backup must be removed. Removal + // of an already-absent rule is tolerated as a no-op (RemoveRule/RemoveNATRule + // are idempotent), so a partially-applied backup can be re-restored cleanly. + current, err := f.GetRules(ctx, zoneName) + if err != nil { + return err + } + currentNAT, err := f.GetNATRules(ctx, zoneName) + if err != nil { + return err + } + for _, r := range current { + // RemoveRule itself dispatches an iptables-file rule to editIPTablesRules, so + // every current rule — whichever form it takes — goes through the same call. + if err := f.RemoveRule(ctx, zoneName, r); err != nil { + return err + } + } + for _, r := range currentNAT { + if err := f.RemoveNATRule(ctx, zoneName, r); err != nil { + return err + } + } + + // Recreate the ipsets now that the current rules are gone (so nothing holds a + // set reference) and before the backup rules that reference them are re-added. + if err := restoreBackupSets(ctx, f, backup, false); err != nil { + return err + } + + // Re-add rules from backup, reproducing their backed-up order. AddRule appends + // an iptables-based rule (inserted before COMMIT in before.rules) but prepends a + // CLI rule (`ufw prepend`, always position 1), so the two groups need opposite + // iteration: append the iptables rules front-to-back, then prepend the CLI rules + // back-to-front so each prepend pushes the earlier rules down and rebuilds the + // original top-to-bottom order. Re-adding CLI rules front-to-back would reverse + // them, inverting first-match evaluation (a specific deny above a broad allow + // would land below it and never fire). + for _, r := range backup.Rules { + if f.needsIPTablesRules(r) { + if err := f.AddRule(ctx, zoneName, r); err != nil { + return err + } + } + } + for i := len(backup.Rules) - 1; i >= 0; i-- { + r := backup.Rules[i] + if f.needsIPTablesRules(r) { + continue + } + if err := f.AddRule(ctx, zoneName, r); err != nil { + return err + } + } + for _, r := range backup.NATRules { + if err := f.AddNATRule(ctx, zoneName, r); err != nil { + return err + } + } + return applyBackupPolicy(ctx, f, zoneName, backup) +} + +// Reload re-applies edits to the iptables rules files; rules added through the +// ufw CLI apply immediately, but edits to those files only take effect after a +// reload. Address sets are staged the same way this backend's raw iptables rules +// are, so they are loaded here too: the sets first, so a rule matching on one +// resolves when ufw reloads, and the destroys owed for removed sets last, once +// the rules that referenced them are gone. +func (f *UFW) Reload(ctx context.Context) error { + sets := f.setHelper() + if err := sets.applyStagedSets(ctx); err != nil { + return err + } + + if f.iptablesRulesChanged { + if _, err := runCommand(ctx, "ufw", "reload"); err != nil { + return err + } + f.iptablesRulesChanged = false + } + + return sets.applyPendingSetRemovals(ctx) +} + +// Close releases any resources held by the backend. +func (f *UFW) Close(ctx context.Context) error { + return nil +} diff --git a/ufw_linux_test.go b/ufw_linux_test.go new file mode 100644 index 0000000..b191f09 --- /dev/null +++ b/ufw_linux_test.go @@ -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) +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..b58f11b --- /dev/null +++ b/utils.go @@ -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 +} diff --git a/wf_windows.go b/wf_windows.go new file mode 100644 index 0000000..d7ea614 --- /dev/null +++ b/wf_windows.go @@ -0,0 +1,1004 @@ +package firewall + +import ( + "context" + "fmt" + "net" + "strings" + + wapi "github.com/iamacarpet/go-win64api" + "go4.org/netipx" +) + +const ( + // The IP protocol numbers for the transport/tunnel protocols the model adds. + // Windows filters by raw protocol number, so these map directly. + wfProtocolGRE = 47 + wfProtocolESP = 50 + wfProtocolAH = 51 + wfProtocolSCTP = 132 + // wfManagedDescription is the default filter description for rules this + // library creates that carry no user comment. It is treated as "no comment" + // on read so it does not surface as a Rule.Comment. + wfManagedDescription = "Managed by go-firewall" +) + +// WF manages firewall rules through the Windows Filtering Platform via the +// go-win64api binding, tagging its rules with the configured name prefix. +type WF struct { + rulePrefix string +} + +// NewWF constructs a WF backend using rulePrefix as its rule-name namespace, +// confirming the Windows firewall interface is reachable. +func NewWF(ctx context.Context, rulePrefix string) (*WF, error) { + // Honor an already-cancelled context before touching the Windows API, whose + // calls are synchronous and cannot be cancelled mid-flight. + if err := ctx.Err(); err != nil { + return nil, err + } + + wf := &WF{ + rulePrefix: rulePrefix, + } + + // Confirm the firewall interface works. + _, err := wapi.FirewallCurrentProfiles() + if err != nil { + return nil, err + } + + // Return the firewall pointer. + return wf, nil +} + +// Type returns the backend type string for the Windows firewall. +func (f *WF) Type() string { + return WFType +} + +// Capabilities returns the set of features the Windows firewall backend can express. +func (f *WF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + IPv6: true, + PortPair: true, + // WFP has no negated address condition, no reject action (permit or + // block only), and scopes a filter's family through its address + // conditions, so an address-less rule cannot pin one family. + Negation: false, + RejectAction: false, + FamilyWithoutAddress: false, + ConnState: false, + InterfaceMatch: false, + Logging: false, + RateLimit: false, + ConnLimit: false, + NAT: false, + RuleOrdering: false, + DefaultPolicy: false, + RuleCounters: false, + AddressSets: false, + Comments: true, + } +} + +// GetZone reports no zone; Windows Firewall is profile-based, so an interface maps to no single zone. +func (f *WF) GetZone(ctx context.Context, iface string) (zoneName string, err error) { + if err := ctx.Err(); err != nil { + return "", err + } + + // Windows Firewall is profile-based (Domain, Private, Public), and an interface + // can carry multiple profiles, so an interface name maps to no single zone. + // Return empty to fall back to profile-wide rules. + return "", nil +} + +// decodeAddress normalizes a Windows Firewall address string to a single CIDR/IP. +func (f *WF) decodeAddress(addr string) (newAddr string, err error) { + addr = strings.TrimSpace(addr) + + // If wildcard or empty, return empty string. + if addr == "*" || addr == "" { + return + } + + // WFP built-in rules frequently carry a comma-separated address list, which the + // single-valued Rule model cannot hold. Decoding only the first entry would + // misrepresent the rule — it would compare equal to a rule scoped to that one + // address, so a rewrite would silently narrow it and a Backup/Restore round trip + // would drop the remaining addresses. Reject it so UnmarshallFWRule keeps the + // rule out of the view entirely, as it does for the other unmodeled shapes. + if strings.IndexByte(addr, ',') >= 0 { + return "", fmt.Errorf("multi-address list cannot be represented") + } + + // Parse IP range to single prefix if possible. + if strings.Contains(addr, "-") { + // Make IP range from parts. + r, err := netipx.ParseIPRange(addr) + if err != nil { + return "", err + } + + // Get prefixes from the range. + prefixes := r.Prefixes() + if len(prefixes) != 1 { + return "", fmt.Errorf("unable to parse range into single prefix") + } + + // Return the found prefix. + return prefixes[0].String(), nil + } + + // Parse prefix from cidr or ip/netmask. + // Example: 192.168.1.0/24 OR 192.168.1.0/255.255.255.0 + if strings.Contains(addr, "/") { + // Parse CIDR or IP/netmask. + var ipNet *net.IPNet + var ip net.IP + + // Split into parts and confirm the length. + parts := strings.Split(addr, "/") + if len(parts) != 2 { + err = fmt.Errorf("invalid prefix length") + return + } + + // The length side is a dotted-quad (IPv4) or colon-form (IPv6) netmask + // rather than a plain prefix length. A plain prefix is all digits, so any + // "." or ":" marks the ip/netmask form. + if strings.ContainsAny(parts[1], ".:") { + // Parse the netmask. + netmask := net.ParseIP(parts[1]) + if netmask == nil { + err = fmt.Errorf("invalid netmask") + return + } + + // Parse the network address. + ip = net.ParseIP(parts[0]) + if ip == nil { + err = fmt.Errorf("invalid ip") + return + } + + // Normalize an IPv4 pair to 4-byte form so the mask width matches the + // address width; leave IPv6 in 16-byte form. A family mismatch between + // the address and the netmask is invalid. + if m4 := netmask.To4(); m4 != nil { + ip4 := ip.To4() + if ip4 == nil { + err = fmt.Errorf("ip/netmask family mismatch") + return + } + netmask, ip = m4, ip4 + } else if ip.To4() != nil { + err = fmt.Errorf("ip/netmask family mismatch") + return + } + mask := net.IPMask(netmask) + + // Make the IP network, masking host bits so the ip/netmask form + // normalizes identically to the equivalent CIDR (net.ParseCIDR masks + // the network address; "192.168.1.5/255.255.255.0" and + // "192.168.1.5/24" must both decode to "192.168.1.0/24" or the two + // spellings would compare unequal in Rule.Equal). + ipNet = &net.IPNet{ + IP: ip.Mask(mask), + Mask: mask, + } + } else { + // For standard CIDRs try and parse normally. + ip, ipNet, err = net.ParseCIDR(addr) + if err != nil { + return + } + } + + // Get CIDR string. + newAddr = ipNet.String() + + // See if this is an individual IP and update new address. + ones, bits := ipNet.Mask.Size() + if ones == bits { + newAddr = ip.String() + } + + // Return the parsed address. + return + } + + // Handle a single IP. + ip := net.ParseIP(addr) + if ip == nil { + err = fmt.Errorf("invalid IP") + return + } + newAddr = ip.String() + + return +} + +// UnmarshallFWRule decodes a Windows FWRule into a Rule, returning nil for a rule the model cannot represent. +func (f *WF) UnmarshallFWRule(fr wapi.FWRule) *Rule { + r := &Rule{} + + // A rule scoped by an attribute this model cannot represent — an application + // path, a Windows service, or a specific interface-type category (LAN, + // Wireless, RemoteAccess) — would decode into a bare, unscoped rule, silently + // widening a narrow foreign rule ("allow inbound TCP for program X") into a + // match-all one ("allow all inbound TCP"). Windows ships many such built-in + // rules; surfacing them misrepresented would let them compare equal to a + // genuinely bare rule and be reconciled or removed as if identical. Drop them + // from the view instead, as the ICMP-code and multi-pair cases below do. Our + // own rules never set these fields (MarshallFWRule cannot express an interface + // match and never sets an application or service), so this hides only foreign + // rules the model cannot faithfully hold. + if fr.ApplicationName != "" || fr.ServiceName != "" { + return nil + } + if it := strings.TrimSpace(fr.InterfaceTypes); it != "" && !strings.EqualFold(it, "All") { + return nil + } + // A disabled rule enforces nothing: decoding it as live would let AddRule's + // idempotency check treat a disabled twin as satisfying the add (the rule is + // then never enforced) and a Backup capture a rule Restore re-adds enabled. + if !fr.Enabled { + return nil + } + // The model has no edge-traversal field; decoding such a rule identically to + // its non-edge twin would let a rewrite silently narrow it. + if fr.EdgeTraversal { + return nil + } + + // Map direction. + if fr.Direction == wapi.NET_FW_RULE_DIR_OUT { + r.Direction = DirOutput + } else { + r.Direction = DirInput + } + + // Map action. + switch fr.Action { + case wapi.NET_FW_ACTION_ALLOW: + r.Action = Accept + case wapi.NET_FW_ACTION_BLOCK: + r.Action = Drop + default: + return nil + } + + // Map protocol. + switch fr.Protocol { + case wapi.NET_FW_IP_PROTOCOL_TCP: + r.Proto = TCP + case wapi.NET_FW_IP_PROTOCOL_UDP: + r.Proto = UDP + case wapi.NET_FW_IP_PROTOCOL_ANY: + r.Proto = ProtocolAny + case wapi.NET_FW_IP_PROTOCOL_ICMPv6: + r.Proto = ICMPv6 + case wapi.NET_FW_IP_PROTOCOL_ICMPv4: + r.Proto = ICMP + case wfProtocolSCTP: + r.Proto = SCTP + case wfProtocolGRE: + r.Proto = GRE + case wfProtocolESP: + r.Proto = ESP + case wfProtocolAH: + r.Proto = AH + default: + return nil + } + + // Decode an ICMP type from the "type:code" field. Only a single type is + // modeled; a "*" (or empty) type matches every type. + if r.Proto.IsICMP() { + raw := strings.TrimSpace(fr.ICMPTypesAndCodes) + if raw != "" && raw != "*" { + // Multiple type:code pairs cannot be represented by a single rule. + if strings.Contains(raw, ",") { + return nil + } + typePart, codePart, hasCode := strings.Cut(raw, ":") + typePart = strings.TrimSpace(typePart) + codePart = strings.TrimSpace(codePart) + // The Rule model carries an ICMP type but no code. A rule scoped to a + // specific code (e.g. "3:4") cannot be represented, and re-adding it would + // emit "3:*" — silently widening it to every code of that type. Drop it + // from the view (like the multi-pair case above) rather than misrepresent + // and then widen it. + if hasCode && codePart != "" && codePart != "*" { + return nil + } + if typePart != "" && typePart != "*" { + // Resolve a named type through the family-appropriate table: ICMPv6 + // reuses several ICMPv4 names for different numbers. Windows stores + // types numerically, where both tables agree, so this only matters if + // a rule carries a named type. + n, ok := parseICMPTypeFamily(typePart, r.Proto == ICMPv6) + if !ok { + return nil + } + r.ICMPType = Ptr(n) + } + } + } + + // Windows uses local/remote ports; map them by direction. For an input rule the + // destination is the local port and the source is the remote port; for an output + // rule it is reversed. Windows expresses each as a string that may hold a list + // and dash ranges (e.g. "80,443,1000-2000"). + destPortsRaw, srcPortsRaw := fr.LocalPorts, fr.RemotePorts + if r.IsOutput() { + destPortsRaw, srcPortsRaw = fr.RemotePorts, fr.LocalPorts + } + if r.Proto == TCP || r.Proto == UDP { + if destPortsRaw != "" && destPortsRaw != "*" { + specs, err := ParsePortRanges(destPortsRaw, ",") + if err != nil { + return nil + } + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.Port = specs[0].Start + } else { + r.Ports = specs + } + } + if srcPortsRaw != "" && srcPortsRaw != "*" { + specs, err := ParsePortRanges(srcPortsRaw, ",") + if err != nil { + return nil + } + if len(specs) == 1 && specs[0].Start == specs[0].End { + r.SourcePort = specs[0].Start + } else { + r.SourcePorts = specs + } + } + } + + // Based on direction, map the source and destination address: the rule model + // uses source/destination, whereas Windows uses local/remote. + var srcRaw, dstRaw string + if r.IsOutput() { + srcRaw = fr.LocalAddresses + dstRaw = fr.RemoteAddresses + } else { + srcRaw = fr.RemoteAddresses + dstRaw = fr.LocalAddresses + } + + // Parse addresses. + var err error + r.Source, err = f.decodeAddress(srcRaw) + if err != nil { + return nil + } + r.Destination, err = f.decodeAddress(dstRaw) + if err != nil { + return nil + } + + // Map family. + r.Family = FamilyAny + if strings.Contains(r.Source, ":") || strings.Contains(r.Destination, ":") { + r.Family = IPv6 + } else if strings.Contains(r.Source, ".") || strings.Contains(r.Destination, ".") { + r.Family = IPv4 + } + + // A description other than our managed default is a user comment. + if fr.Description != "" && fr.Description != wfManagedDescription { + r.Comment = fr.Description + } + + return r +} + +// hasPrefix reports whether a listed rule's name carries the configured prefix +// (see MarshallFWRule), marking it as one this manager tagged. Everything else — +// notably Windows' many built-in rules — reports false. With no prefix the +// manager has no namespace of its own, so no rule reports HasPrefix. +func (f *WF) hasPrefix(fr wapi.FWRule) bool { + return f.rulePrefix != "" && strings.HasPrefix(fr.Name, f.rulePrefix+" ") +} + +// profileFilter maps a zone name to the single Windows profile bit that GetRules +// and RemoveRule filter on, so both scope to the same rules. ok is false when the +// zone names no specific profile, meaning every profile is in scope. +func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) { + switch { + case strings.EqualFold(zoneName, "public"): + return wapi.NET_FW_PROFILE2_PUBLIC, true + case strings.EqualFold(zoneName, "private"): + return wapi.NET_FW_PROFILE2_PRIVATE, true + case strings.EqualFold(zoneName, "domain"): + return wapi.NET_FW_PROFILE2_DOMAIN, true + } + return 0, false +} + +// profileMatches reports whether a rule's Profiles bitmask is in scope for a +// zone's profile filter (every rule is, when the zone named no profile). A rule +// matches only when its Profiles exactly equals the single filter bit: AddRule +// always stores a named zone's rule under exactly one profile bit or the +// all-profiles default (never a combination), so an exact-equality test is what +// keeps a specific zone's rules disjoint from another zone's and from an +// all-profiles rule. Testing overlap instead (fr.Profiles&profile != 0) would +// let a single-zone query and, worse, a single-zone RemoveRule/Sync match and +// delete an all-profiles rule — silently affecting every other zone too. +func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool { + if !useFilter { + return true + } + return rulesProfiles == filterProfile +} + +// GetRules returns the existing filter rules from the zone. +func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + fwRules, err := wapi.FirewallRulesGet() + if err != nil { + return nil, fmt.Errorf("failed to fetch firewall rules: %w", err) + } + + // Filter by profile if a zone names one. + filterProfile, useFilter := f.profileFilter(zoneName) + + // Parse all rules. Windows ships hundreds of built-in rules; rather than hide + // them, every rule is surfaced with HasPrefix reporting whether this manager + // tagged it (identified by the configured name prefix), so callers can tell + // them apart. + for _, fr := range fwRules { + // If filtered by profile, skip rules not scoped to exactly this profile. + if !f.profileMatches(fr.Profiles, filterProfile, useFilter) { + continue + } + + // Decode the rule and skip it if it cannot be decoded. + r := f.UnmarshallFWRule(fr) + if r == nil { + continue + } + r.HasPrefix = f.hasPrefix(fr) + + // Add decoded rule to list. + rules = append(rules, r) + } + + // Every filter above is reported as WFP stores it. WFP has no per-rule family + // selector, so a FamilyAny rule is one dual-family filter and reads back as + // FamilyAny on its own. Its protocol field carries a single protocol number and + // its direction field a single direction, so a TCPUDP rule is a tcp filter plus a + // udp filter and a DirAny rule an inbound plus an outbound filter — each reported + // as its own rule. + return rules, nil +} + +// validateRule reports whether the Windows Filtering Platform can express the +// filter rule, applying the universal Rule.validate and then the WFP-specific +// shape constraints, so MarshallFWRule builds a rule already known to be +// expressible. The per-rule logging/limit gate stays in AddRule/RemoveRule +// (rejectLogAndLimit), which is where those requests are rejected today. +func (f *WF) validateRule(r *Rule) error { + if err := r.validate(); err != nil { + return err + } + // A WFP filter carries one protocol number, so the TCPUDP protocol has no + // single-filter form; AddRule/RemoveRule fan it into a tcp filter and a udp + // filter with expandProtocols first, so a TCPUDP rule here means that fan-out + // was skipped. + if err := r.CheckExpandedProtocol(); err != nil { + return err + } + // The Windows Firewall rule model has only inbound and outbound directions; + // forwarded (routed) traffic is handled out of band (RRAS/portproxy). + if r.IsForward() { + return unsupportedForward("windows firewall") + } + // A WFP filter likewise carries one direction; AddRule/RemoveRule fan a DirAny + // rule out first, so a DirAny rule here means that fan-out was skipped. + if r.Direction == DirAny { + return fmt.Errorf("a both-directions rule must be expanded before marshalling") + } + // Windows only matches ports for TCP and UDP; a port on any other protocol + // (e.g. SCTP) has no representation, so reject rather than silently drop it. + if (r.HasPorts() || r.HasSourcePorts()) && r.Proto != TCP && r.Proto != UDP { + return fmt.Errorf("windows firewall only matches ports for tcp or udp: %w", ErrUnsupported) + } + // Features this backend cannot express are rejected up front rather than + // silently dropped. Windows firewall rules are stateful by default, and the + // go-win64api binding cannot set the interface fields, so neither a + // connection-state nor a per-rule interface match is expressible here. + if r.State != 0 { + return fmt.Errorf("windows firewall does not support connection-state matching in this model: %w", ErrUnsupportedState) + } + if r.InInterface != "" || r.OutInterface != "" { + return fmt.Errorf("windows firewall does not support per-rule interface matching in this model: %w", ErrUnsupportedInterface) + } + // WFP has no reject action; mapping Reject to Drop would make a rule added as + // Reject unreadable as Reject and impossible to reconcile. + if r.Action == Reject { + return fmt.Errorf("windows firewall has no reject action: %w", ErrUnsupported) + } + // WFP cannot express address negation (decodeAddress rejects a '!'-prefixed + // token on read), so reject it rather than passing an invalid address string to + // Windows and producing a rule that can never be read back or removed. + if strings.HasPrefix(strings.TrimSpace(r.Source), "!") || strings.HasPrefix(strings.TrimSpace(r.Destination), "!") { + return fmt.Errorf("windows firewall does not support address negation in this model: %w", ErrUnsupported) + } + // A WFP rule carries an IP family only through an address or an ICMP protocol. + // An explicit Family with neither cannot be honored: applying the rule to both + // families widens it, and it reads back as FamilyAny so it can never reconcile. + if r.Family != FamilyAny && !r.Proto.IsICMP() && + familyOfAddr(r.Source) == FamilyAny && familyOfAddr(r.Destination) == FamilyAny { + return fmt.Errorf("windows firewall cannot scope a rule to an IP family without an address; use family any or add an address: %w", ErrUnsupported) + } + return nil +} + +// MarshallFWRule encodes a Rule as a Windows FWRule for the given zone. It is a +// pure encoder: callers run validateRule on the fanned-out rule first. +func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) { + // Setup base rule. A user comment is carried in the filter description; + // otherwise the managed default marks the rule as ours. + fwRule := &wapi.FWRule{ + Description: wfManagedDescription, + Enabled: true, + } + if r.Comment != "" { + fwRule.Description = r.Comment + } + + // Base rule name. + // Format: [prefix] [dir] [proto] [port X] [from S] [to D] [allow/block] + var nameParts []string + if f.rulePrefix != "" { + nameParts = append(nameParts, f.rulePrefix) + } + + // Set the profile based on provided zone, and fold the profile into the rule + // name. RemoveRule deletes by name, so two otherwise-identical rules added to + // different profiles must get distinct names — otherwise removing one deletes + // the other. An all-profiles (default) rule keeps the plain name. + switch strings.ToLower(zoneName) { + case "private": + fwRule.Profiles = wapi.NET_FW_PROFILE2_PRIVATE + nameParts = append(nameParts, "private") + case "public": + fwRule.Profiles = wapi.NET_FW_PROFILE2_PUBLIC + nameParts = append(nameParts, "public") + case "domain": + fwRule.Profiles = wapi.NET_FW_PROFILE2_DOMAIN + nameParts = append(nameParts, "domain") + default: + fwRule.Profiles = wapi.NET_FW_PROFILE2_ALL + } + + // Based on the rule direction. + if r.IsOutput() { + nameParts = append(nameParts, "out") + fwRule.Direction = wapi.NET_FW_RULE_DIR_OUT + } else { + nameParts = append(nameParts, "in") + fwRule.Direction = wapi.NET_FW_RULE_DIR_IN + } + + // Set the protocol. + fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_ANY + switch r.Proto { + case TCP: + fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_TCP + nameParts = append(nameParts, "tcp") + case UDP: + fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_UDP + nameParts = append(nameParts, "udp") + case ICMP: + fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_ICMPv4 + nameParts = append(nameParts, "icmp") + case ICMPv6: + fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_ICMPv6 + nameParts = append(nameParts, "icmpv6") + case SCTP: + fwRule.Protocol = wfProtocolSCTP + nameParts = append(nameParts, "sctp") + case GRE: + fwRule.Protocol = wfProtocolGRE + nameParts = append(nameParts, "gre") + case ESP: + fwRule.Protocol = wfProtocolESP + nameParts = append(nameParts, "esp") + case AH: + fwRule.Protocol = wfProtocolAH + nameParts = append(nameParts, "ah") + } + + // An ICMP type match is expressed via the ICMPTypesAndCodes field as + // "type:code"; a "*" code matches every code of that type. + if r.Proto.IsICMP() && r.ICMPType != nil { + fwRule.ICMPTypesAndCodes = fmt.Sprintf("%d:*", *r.ICMPType) + nameParts = append(nameParts, "type", fmt.Sprintf("%d", *r.ICMPType)) + } + + // If TCP/UDP, and a port set is defined, add the ports. Windows firewall + // accepts a comma list with dash ranges (e.g. "80,443,1000-2000"). + dstSpecs := r.PortSpecs() + if (r.Proto == TCP || r.Proto == UDP) && len(dstSpecs) > 0 { + portS := FormatPortRanges(dstSpecs, ",") + nameParts = append(nameParts, "port", portS) + + // Set remote/local based on direction. + if r.IsOutput() { + fwRule.RemotePorts = portS + } else { + fwRule.LocalPorts = portS + } + } + + // Source ports are mapped to the opposite side of direction. + srcSpecs := r.SourcePortSpecs() + if (r.Proto == TCP || r.Proto == UDP) && len(srcSpecs) > 0 { + portS := FormatPortRanges(srcSpecs, ",") + nameParts = append(nameParts, "sport", portS) + + if r.IsOutput() { + fwRule.LocalPorts = portS + } else { + fwRule.RemotePorts = portS + } + } + + // Add addresses according to direction due to difference in local/remote vs source/dest. + if r.IsOutput() { + fwRule.LocalAddresses = r.Source + fwRule.RemoteAddresses = r.Destination + } else { + fwRule.RemoteAddresses = r.Source + fwRule.LocalAddresses = r.Destination + } + + // Append addresses to name if present. + if r.Source != "" { + nameParts = append(nameParts, "from", r.Source) + } + if r.Destination != "" { + nameParts = append(nameParts, "to", r.Destination) + } + + // Set the rule action. + if r.Action == Accept { + nameParts = append(nameParts, "allow") + fwRule.Action = wapi.NET_FW_ACTION_ALLOW + } else { + nameParts = append(nameParts, "block") + fwRule.Action = wapi.NET_FW_ACTION_BLOCK + } + + fwRule.Name = strings.Join(nameParts, " ") + + // Set the grouping. + fwRule.Grouping = f.rulePrefix + + return fwRule, nil +} + +// rejectLogAndLimit reports a logging or rate/connection-limit request on a +// backend whose per-rule model cannot express it. Such a rule is rejected +// rather than applied without the modifier. It returns nil when the rule asks +// for none of these. backend names the backend for the error message. WFP is +// the only such backend, so the helper lives here beside its caller. +func (r *Rule) rejectLogAndLimit(backend string) error { + switch { + case r.Log: + return fmt.Errorf("%s does not support per-rule logging in this model: %w", backend, ErrUnsupportedLog) + case r.RateLimit != nil: + return fmt.Errorf("%s does not support rate limiting in this model: %w", backend, ErrUnsupportedRateLimit) + case r.ConnLimit != nil: + return fmt.Errorf("%s does not support connection limiting in this model: %w", backend, ErrUnsupportedConnLimit) + } + return nil +} + +// AddRule adds a filter rule scoped to the zone's Windows profile (or the +// all-profiles default for an unnamed zone). It is idempotent: an equivalent +// existing rule makes it a no-op. +func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error { + if err := ctx.Err(); err != nil { + return err + } + + if r == nil { + return fmt.Errorf("rule cannot be nil") + } + + // A TCPUDP rule fans out into a tcp filter plus a udp filter; a WFP filter + // carries one protocol number, so it has no single-filter form. + if r.Proto == TCPUDP { + for _, sub := range expandProtocols(r) { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // A DirAny rule fans out into an inbound filter plus its role-swapped outbound + // filter; WFP stores each direction as its own rule object. + if r.Direction == DirAny { + for _, sub := range expandDirections(r) { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + if err := r.rejectLogAndLimit(f.Type()); err != nil { + return err + } + + // Verify the rule is valid for WFP. The fan-outs above run first: validateRule + // asserts the TCPUDP split has already happened. + if err := f.validateRule(r); err != nil { + return err + } + + // Encode the rule. + fwRule, err := f.MarshallFWRule(zoneName, r) + if err != nil { + return fmt.Errorf("failed to marshall rule: %w", err) + } + + // Skip if an equivalent rule already exists: Windows rejects a duplicate rule + // name, and AddRule is expected to be idempotent like the other backends. + if existing, gerr := f.GetRules(ctx, zoneName); gerr == nil { + for _, e := range existing { + // Any equivalent rule already in the firewall counts as a duplicate + // (Windows rejects a duplicate rule name), so the add stays idempotent. + if e.EqualBase(r, true) { + return nil + } + } + } + + // Attempt to add the rule. FirewallRuleAddAdvanced reports success=false with a + // nil error in exactly one case: a rule already exists under this exact Name; + // every other failure carries a non-nil error. That is a benign duplicate (a + // name collision the EqualBase check above missed, e.g. a concurrent or foreign + // add), so treat it as a no-op rather than a failure. + success, err := wapi.FirewallRuleAddAdvanced(*fwRule) + if err != nil { + return fmt.Errorf("failed to add firewall rule %q: %w", fwRule.Name, err) + } + if !success { + return nil + } + + return nil +} + +// InsertRule is unsupported: Windows Filtering Platform rules are not +// positionally ordered in the same way as iptables/nftables. +func (f *WF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + return unsupportedOrdering(f.Type()) +} + +// MoveRule is unsupported for the same reason as InsertRule. +func (f *WF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error { + return unsupportedOrdering(f.Type()) +} + +// RemoveRule removes a rule from the zone. +func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + if err := ctx.Err(); err != nil { + return err + } + + // A TCPUDP target removes both its tcp filter and its udp filter; WFP stores only + // concrete-protocol filters, so removing the tcp half leaves the udp filter and + // vice versa (the surviving transport re-adds nothing, unlike a container backend). + if r.Proto == TCPUDP { + for _, sub := range expandProtocols(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // A DirAny target removes both its inbound and its role-swapped outbound filter. + if r.Direction == DirAny { + for _, sub := range expandDirections(r) { + if err := f.RemoveRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + // A WFP rule carries an IP family only through an address or an ICMP protocol, + // so a concrete-family rule with neither is unexpressible (see AddRule) — and a + // FamilyAny bare rule is stored as one dual-family filter. A concrete-family + // removal of that shape could only be honored by dropping the dual filter, which + // takes the untargeted family with it; the surviving single-family rule cannot + // be re-added without an address. Reject it rather than over-remove. + if r.Family != FamilyAny && !r.Proto.IsICMP() && + familyOfAddr(r.Source) == FamilyAny && familyOfAddr(r.Destination) == FamilyAny { + return fmt.Errorf("windows firewall cannot scope a removal to an IP family without an address; use family any or add an address: %w", ErrUnsupported) + } + + // Get a list of existing rules. + fwRules, err := wapi.FirewallRulesGet() + if err != nil { + return fmt.Errorf("failed to list rules for deletion: %w", err) + } + + // Scope the deletion to the same profile GetRules/AddRule use for this zone, so + // each zone stays isolated: the manager adds and lists rules per profile, so it + // must also remove them per profile. profileMatches' exact-equality match (not + // overlap) is what keeps this from also deleting an all-profiles rule when + // zoneName names a single zone. + filterProfile, useFilter := f.profileFilter(zoneName) + + // Windows deletes by name and removes the FIRST rule bearing it, so a name + // shared with a rule this removal did not match (a per-profile twin of a + // built-in rule, say) could destroy the wrong rule. Count each name's holders + // and how many of them this removal matches: when every holder matches, + // repeated by-name deletes are safe; otherwise the delete is ambiguous and is + // refused rather than risked. + nameTotal := map[string]int{} + nameMatched := map[string]int{} + type target struct{ name string } + var targets []target + for _, fr := range fwRules { + nameTotal[fr.Name]++ + + // Skip rules outside the target profile, matching GetRules' filter. + if !f.profileMatches(fr.Profiles, filterProfile, useFilter) { + continue + } + + // Decode the rule, and skip if it can't be decoded. + rule := f.UnmarshallFWRule(fr) + if rule == nil { + continue + } + + // EqualBase ignores the IP family because Windows records a concrete + // family on the rule it lists back even when the added rule left it unset + // (mirroring GetRules). Removal is idempotent, matching the other + // backends: a rule that is not present is not an error. + if r.EqualBase(rule, true) { + nameMatched[fr.Name]++ + targets = append(targets, target{fr.Name}) + } + } + for _, t := range targets { + if nameTotal[t.name] != nameMatched[t.name] { + return fmt.Errorf("cannot delete rule %q: another rule shares the name and windows deletes by name", t.name) + } + ok, err := wapi.FirewallRuleDelete(t.name) + if err != nil { + return fmt.Errorf("failed to delete rule %q: %w", t.name, err) + } + if !ok { + return fmt.Errorf("failed to delete rule %q: reported failure", t.name) + } + } + + return nil +} + +// GetNATRules is unsupported; WFP is a stateful packet filter only and NAT on +// Windows is handled out of band (netsh portproxy or RRAS). +func (f *WF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) { + return nil, unsupportedNAT(f.Type()) +} + +// AddNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules). +func (f *WF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + return unsupportedNAT(f.Type()) +} + +// InsertNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules). +func (f *WF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error { + return unsupportedNAT(f.Type()) +} + +// MoveNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules). +func (f *WF) MoveNATRule(ctx context.Context, zoneName string, r *NATRule, position int) error { + return unsupportedNAT(f.Type()) +} + +// RemoveNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules). +func (f *WF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + return unsupportedNAT(f.Type()) +} + +// GetDefaultPolicy is unsupported; the Windows firewall exposes no default policy in this model. +func (f *WF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + return nil, unsupportedPolicy(f.Type()) +} + +// SetDefaultPolicy is unsupported; the Windows firewall exposes no default policy in this model. +func (f *WF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error { + return unsupportedPolicy(f.Type()) +} + +// GetAddressSets is unsupported; the Windows firewall backend has no address sets. +func (f *WF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + return nil, unsupportedSet(f.Type()) +} + +// GetAddressSet is unsupported; the Windows firewall backend has no address sets. +func (f *WF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + return nil, unsupportedSet(f.Type()) +} + +// AddAddressSet is unsupported; the Windows firewall backend has no address sets. +func (f *WF) AddAddressSet(ctx context.Context, set *AddressSet) error { + return unsupportedSet(f.Type()) +} + +// RemoveAddressSet is unsupported; the Windows firewall backend has no address sets. +func (f *WF) RemoveAddressSet(ctx context.Context, name string) error { + return unsupportedSet(f.Type()) +} + +// AddAddressSetEntry is unsupported; the Windows firewall backend has no address sets. +func (f *WF) AddAddressSetEntry(ctx context.Context, name, entry string) error { + return unsupportedSet(f.Type()) +} + +// RemoveAddressSetEntry is unsupported; the Windows firewall backend has no address sets. +func (f *WF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + return unsupportedSet(f.Type()) +} + +// Backup captures every decodable filter rule in the zone, foreign rules +// included; the library manages the actual firewall state. +func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) { + rules, err := f.GetRules(ctx, zoneName) + if err != nil { + return nil, err + } + // Backup captures the full filter rule state; Restore reconciles the live rules + // to this set, so every rule read is preserved. + return &Backup{Rules: rules}, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +func (f *WF) Restore(ctx context.Context, zoneName string, backup *Backup) error { + if backup == nil { + return fmt.Errorf("backup cannot be nil") + } + + // Reconcile the live rule set to the backup with a minimal add/remove diff + // rather than removing every rule and re-adding it. Removing all rules first + // leaves a window with no matching filter, and WFP drops in-flight connections + // that no longer match one — including a foreign inbound-allow rule the backup + // itself captured (e.g. the rule keeping this host reachable over SSH while a + // remote restore runs). Sync leaves a rule present in both the firewall and the + // backup untouched, so such a rule is never briefly removed. WFP has no NAT, so + // backup.NATRules is not applied here. + _, _, err := Sync(ctx, f, zoneName, backup.Rules) + return err +} + +// Reload is a no-op; Windows Firewall applies rule changes immediately. +func (f *WF) Reload(ctx context.Context) error { + return nil +} + +// Close releases any resources held by the backend; the Windows firewall holds none. +func (f *WF) Close(ctx context.Context) error { + return nil +} diff --git a/wf_windows_test.go b/wf_windows_test.go new file mode 100644 index 0000000..8f7ff97 --- /dev/null +++ b/wf_windows_test.go @@ -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") +}