From 98aa68f7edc9b191c4e3b1f6dcdd09e5e6ab182c Mon Sep 17 00:00:00 2001 From: James Coleman Date: Wed, 8 Jul 2026 15:54:48 -0500 Subject: [PATCH] first commit --- .gitignore | 11 + .golangci.yml | 31 + LICENSE | 19 + Makefile | 91 + README.md | 308 ++ apf_linux.go | 2196 ++++++++++++ apf_linux_test.go | 819 +++++ backup.go | 275 ++ capabilities_linux_test.go | 120 + cmd/go-firewall/backup.go | 97 + cmd/go-firewall/cli_test.go | 317 ++ cmd/go-firewall/go.mod | 34 + cmd/go-firewall/go.sum | 143 + cmd/go-firewall/info.go | 138 + cmd/go-firewall/main.go | 166 + cmd/go-firewall/nat.go | 216 ++ cmd/go-firewall/output.go | 239 ++ cmd/go-firewall/parse.go | 48 + cmd/go-firewall/policy.go | 117 + cmd/go-firewall/rule.go | 350 ++ cmd/go-firewall/set.go | 189 + configfile.go | 118 + configfile_other.go | 11 + configfile_test.go | 157 + configfile_unix.go | 18 + csf_linux.go | 1947 ++++++++++ csf_linux_test.go | 888 +++++ firewall.go | 2268 ++++++++++++ firewall_test.go | 1379 +++++++ firewalld_linux.go | 1611 +++++++++ firewalld_linux_test.go | 576 +++ go.mod | 26 + go.sum | 120 + hooks_linux.go | 393 ++ hooks_linux_test.go | 448 +++ integration_darwin_test.go | 19 + integration_freebsd_test.go | 16 + integration_linux_test.go | 26 + integration_test.go | 1848 ++++++++++ integration_windows_test.go | 19 + iptables_linux.go | 3162 +++++++++++++++++ iptables_linux_test.go | 1801 ++++++++++ manager_darwin.go | 17 + manager_freebsd.go | 17 + manager_linux.go | 51 + manager_windows.go | 16 + nft_linux.go | 2222 ++++++++++++ nft_linux_test.go | 661 ++++ pf.go | 2119 +++++++++++ pf_test.go | 603 ++++ .../refactor_order.cpython-314.pyc | Bin 0 -> 11913 bytes scripts/refactor_backend.py | 380 ++ scripts/refactor_order.py | 245 ++ test/integration/.gitignore | 2 + test/integration/guest-linux-run.sh | 438 +++ test/integration/host-freebsd-vm.sh | 196 + test/integration/host-linux-vm.sh | 230 ++ test/integration/host-windows-vm.sh | 205 ++ ufw_linux.go | 1588 +++++++++ ufw_linux_test.go | 628 ++++ utils.go | 166 + wf_windows.go | 906 +++++ wf_windows_test.go | 296 ++ 63 files changed, 33761 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 apf_linux.go create mode 100644 apf_linux_test.go create mode 100644 backup.go create mode 100644 capabilities_linux_test.go create mode 100644 cmd/go-firewall/backup.go create mode 100644 cmd/go-firewall/cli_test.go create mode 100644 cmd/go-firewall/go.mod create mode 100644 cmd/go-firewall/go.sum create mode 100644 cmd/go-firewall/info.go create mode 100644 cmd/go-firewall/main.go create mode 100644 cmd/go-firewall/nat.go create mode 100644 cmd/go-firewall/output.go create mode 100644 cmd/go-firewall/parse.go create mode 100644 cmd/go-firewall/policy.go create mode 100644 cmd/go-firewall/rule.go create mode 100644 cmd/go-firewall/set.go create mode 100644 configfile.go create mode 100644 configfile_other.go create mode 100644 configfile_test.go create mode 100644 configfile_unix.go create mode 100644 csf_linux.go create mode 100644 csf_linux_test.go create mode 100644 firewall.go create mode 100644 firewall_test.go create mode 100644 firewalld_linux.go create mode 100644 firewalld_linux_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hooks_linux.go create mode 100644 hooks_linux_test.go create mode 100644 integration_darwin_test.go create mode 100644 integration_freebsd_test.go create mode 100644 integration_linux_test.go create mode 100644 integration_test.go create mode 100644 integration_windows_test.go create mode 100644 iptables_linux.go create mode 100644 iptables_linux_test.go create mode 100644 manager_darwin.go create mode 100644 manager_freebsd.go create mode 100644 manager_linux.go create mode 100644 manager_windows.go create mode 100644 nft_linux.go create mode 100644 nft_linux_test.go create mode 100644 pf.go create mode 100644 pf_test.go create mode 100644 scripts/__pycache__/refactor_order.cpython-314.pyc create mode 100644 scripts/refactor_backend.py create mode 100644 scripts/refactor_order.py create mode 100644 test/integration/.gitignore create mode 100755 test/integration/guest-linux-run.sh create mode 100755 test/integration/host-freebsd-vm.sh create mode 100755 test/integration/host-linux-vm.sh create mode 100755 test/integration/host-windows-vm.sh create mode 100644 ufw_linux.go create mode 100644 ufw_linux_test.go create mode 100644 utils.go create mode 100644 wf_windows.go create mode 100644 wf_windows_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bed78b --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# 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 + diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ab8fb01 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,31 @@ +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 +# backend plus all 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. + +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..7ea8001 --- /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 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 general + Linux integration +# make test-all general + every OS's integration (slow; big downloads) +# 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 For 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..ec98cac --- /dev/null +++ b/README.md @@ -0,0 +1,308 @@ +# go-firewall + +[![Go Reference](https://pkg.go.dev/badge/github.com/grmrgecko/firewall.svg)](https://pkg.go.dev/github.com/grmrgecko/firewall) + +A Go module that presents a single, uniform interface over the many +firewall managers found across operating systems. You describe rules with one +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/firewall" +``` + +## Supported backends + +| Platform | Backends | +| -------- | --------------------------------------------------------------- | +| Linux | firewalld → ufw → CSF → APF → iptables → nftables | +| macOS | pf (Packet Filter) | +| FreeBSD | pf (Packet Filter) | +| Windows | Windows Filtering Platform (WFP) | + +## Usage + +```go +package main + +import ( + "context" + "log" + + "github.com/grmrgecko/firewall" +) + +func main() { + ctx := context.Background() + + // Detect and connect to the host's firewall. The rule prefix tags/namespaces + // rules this module creates. + mgr, err := firewall.NewManager(ctx, "myapp") + if err != nil { + log.Fatal(err) + } + defer mgr.Close(ctx) + + // Resolve the zone for an interface (empty for backends without zones). + zone, err := mgr.GetZone(ctx, "eth0") + if err != nil { + log.Fatal(err) + } + + // Allow inbound TCP 443 from a subnet, logged and rate-limited. + rule := &firewall.Rule{ + Family: firewall.IPv4, + Source: "192.168.0.0/24", + Port: 443, + Proto: firewall.TCP, + Action: firewall.Accept, + Log: true, + LogPrefix: "https", + RateLimit: &firewall.RateLimit{Rate: 20, Unit: firewall.PerSecond, Burst: 10}, + } + + if err := mgr.AddRule(ctx, zone, rule); err != nil { + log.Fatal(err) + } + + // Forward inbound TCP 8080 to an internal host (a NAT rule). + nat := &firewall.NATRule{ + Kind: firewall.DNAT, + Family: firewall.IPv4, + Proto: firewall.TCP, + Port: 8080, + ToAddress: "10.0.0.5", + ToPort: 80, + } + if err := mgr.AddNATRule(ctx, zone, nat); err != nil { + log.Fatal(err) + } + + // Some backends stage changes; Reload activates them (a no-op where + // changes apply immediately). + if err := mgr.Reload(ctx); err != nil { + log.Fatal(err) + } +} +``` + +## CLI + +`cmd/go-firewall` is a unified firewall management CLI and implementation demo +for the library. It auto-detects the host's active backend and exposes the same +surface across all of them. Build it from the repo root: + +```sh +make cli # builds ./build/go-firewall +make install # installs into $GOBIN +``` + +Managing rules needs appropriate privileges (root/Administrator), and the CLI +never modifies the host unless you run a mutating subcommand. + +```sh +go-firewall status # backend + capabilities +go-firewall rule list # all filter rules (PREFIX column flags ours) +go-firewall rule add --proto tcp --port 443 --source 192.168.0.0/24 --log +go-firewall rule add --proto tcp --ports 80,443,1000-2000 --comment "web" +go-firewall rule remove --proto tcp --port 443 +go-firewall rule insert 1 --proto tcp --port 22 # 1-based position +go-firewall nat add --kind dnat --proto tcp --port 8080 --to-address 10.0.0.5 --to-port 80 +go-firewall nat insert 1 --kind dnat --proto tcp --port 8080 --to-address 10.0.0.5 --to-port 80 +go-firewall nat add --kind masquerade +go-firewall policy get +go-firewall policy set --input drop --forward drop +go-firewall set create blocklist --family ipv4 --type hash:net +go-firewall set add-entry blocklist 203.0.113.0/24 +go-firewall set show blocklist # metadata + every entry +go-firewall backup -o snapshot.json # portable JSON snapshot (rules, NAT, policy, sets) +go-firewall restore -f snapshot.json # replay the snapshot +go-firewall zone eth0 +go-firewall reload +go-firewall install-completions # bash/zsh/fish completion +``` + +Global flags: `--prefix` (rule namespace; default `go_firewall`), `--no-reload` +(skip the automatic reload after a mutation), `-j/--json` (machine-readable +output — list, status, and a `{"status":...}` object on mutations), `--version`. +A rule's flags are identical across `add`, +`remove`, `insert` and `move`, so the flag set that creates a rule is also its +match key for removal. Run `go-firewall --help` for the full flag +reference. + +## The `Rule` type + +| Field | Meaning | +| -------------- | -------------------------------------------------------------------------------- | +| `Direction` | `DirInput` (default), `DirOutput`, `DirForward`, or `DirAny` — the input, output, forward (routing) chain, or both input and output. See `Capabilities().Forward` and the `DirAny` note below. | +| `Priority` | Rule priority, where the backend supports it (e.g. firewalld rich rules). | +| `Family` | `FamilyAny`, `IPv4`, or `IPv6`. | +| `Source` | Source address/CIDR. Prefix with `!` to negate, where supported. | +| `Destination` | Destination address/CIDR. Prefix with `!` to negate, where supported. | +| `Port` | Single destination port. A non-zero port requires a concrete `TCP`/`UDP` proto. | +| `Ports` | Destination port list/ranges (`[]PortRange`). Overrides `Port` when non-empty. | +| `Proto` | `ProtocolAny`, `TCP`, `UDP`, `ICMP`, `ICMPv6`, `SCTP`, `GRE`, `ESP`, or `AH`. | +| `ICMPType` | Optional single ICMP type for an `ICMP`/`ICMPv6` rule (`*uint8`, nil = any type). | +| `State` | Connection-tracking states to match, OR-combined (e.g. `StateEstablished\|StateRelated`). | +| `InInterface` | Inbound interface to match. Empty means any interface. A forward rule may match this alongside `OutInterface`. | +| `OutInterface` | Outbound interface to match. Empty means any interface. A forward rule may match this alongside `InInterface`. | +| `Action` | `Accept`, `Reject`, or `Drop`. | +| `Log` | Log each matched packet before applying `Action`. | +| `LogPrefix` | Optional label on the log line (not all backends carry a prefix; pf ignores it). | +| `RateLimit` | `*RateLimit` (`Rate`/`Unit`/`Burst`) — cap the packet rate the rule matches. `nil` = unlimited. | +| `ConnLimit` | `*ConnLimit` (`Count`/`PerSource`) — cap concurrent connections. `nil` = unlimited. | +| `Packets` | Per-rule packet counter, populated by `GetRules` on backends that read them (nftables, iptables, pf). Zero elsewhere and ignored when adding a rule. | +| `Bytes` | Per-rule byte counter, populated alongside `Packets`. Not part of rule identity. | +| `Comment` | Optional human-readable label carried where the backend can store one. Informational: not part of rule identity, ignored where unsupported. See `Capabilities().Comments`. | +| `HasPrefix` | Informational flag reporting whether the rule carries the configured prefix. | + +A `FamilyAny` rule that resolves to an identical IPv4 and IPv6 pair is collapsed +into a single rule when reading rules back. `Capabilities().Output` reports whether +a backend distinguishes input from output (firewalld, for example, does not), and +`Capabilities().Forward` reports whether it can express a forward-chain (routing) +rule. A `DirForward` rule on a backend without forward support is rejected with +`ErrUnsupportedForward`. + +### `DirAny` — both directions + +`DirAny` is the direction analog of `FamilyAny`: it describes a rule that applies +to **both** the input and output directions (never forward). A `DirAny` rule is +authored in the inbound frame — its `Source`/destination ports/`InInterface` are +the input-chain meaning — and its outbound half is the **role swap**: +`Source`↔`Destination`, source↔destination ports, and `InInterface`↔`OutInterface`. +So `DirAny` with `Source: X` matches inbound traffic *from* `X` and outbound +traffic *to* `X`. + +- **On add**, a `DirAny` rule fans out into a concrete input row plus its swapped + output row. **On read**, an input rule and its swapped output twin are collapsed + back into one `DirAny` rule (the same way a v4/v6 pair collapses to `FamilyAny`). +- **Removing a single direction** of a `DirAny` rule leaves the other in place: the + chain backends drop only that direction's row, while csf/apf split their + bidirectional plain `csf.allow`/`allow_hosts` line and re-express the surviving + direction through their raw-iptables hook. +- On csf/apf, a bare (address-only, no-port) `DirAny` host allow is the single + bidirectional plain line; a one-way (`DirInput`/`DirOutput`) bare host allow is + written to the hook instead, since a plain line is inherently bidirectional and an + advanced rule requires a port. +- On a backend that does not distinguish an output chain (`Capabilities().Output` + is false, e.g. **firewalld**), a both-directions rule cannot be expressed, so a + `DirAny` rule degrades to its input half (`DirInput`, same fields) rather than + being rejected. + +## NAT (port forwarding and masquerade) + +NAT rules are managed separately from filter rules through +`AddNATRule`/`RemoveNATRule`/`GetNATRules`, using the `NATRule` type. + +| Field | Meaning | +| ----------- | -------------------------------------------------------------------------------- | +| `Kind` | `DNAT`, `Redirect`, `SNAT`, or `Masquerade`. | +| `Family` | `FamilyAny`, `IPv4`, or `IPv6`. | +| `Proto` | Protocol to match (`TCP`, `UDP`, etc.). | +| `Port` | Matched destination port (`Ports` for a list/range). Requires a tcp/udp protocol. | +| `ToAddress` | Rewrite target address: new destination for `DNAT`, new source for `SNAT`. | +| `ToPort` | Rewrite target port (`DNAT`/`Redirect`). Unused for `SNAT`/`Masquerade`. | +| `Interface` | Inbound interface for `DNAT`/`Redirect`; outbound for `SNAT`/`Masquerade`. | +| `HasPrefix` | Informational flag, same semantics as `Rule.HasPrefix`. | + +`DNAT` forwards inbound traffic to `ToAddress:ToPort`. `Redirect` sends matching +traffic to a local `ToPort`. `SNAT` rewrites the source to a fixed address, and +`Masquerade` uses the outgoing interface address. Backends that cannot express +NAT return `ErrUnsupportedNAT`. + +## Capabilities + +`mgr.Capabilities()` returns a `Capabilities` struct advertising which features +the active backend can express, so a caller can branch before trial-and-error: + +```go +caps := mgr.Capabilities() +if !caps.NAT { + log.Println("this backend cannot do NAT") +} +if caps.RuleCounters { + // rules read back will carry Packets/Bytes +} +``` + +Every boolean corresponds to a `Rule`/`NATRule` field or an interface method +(`NAT`, `RuleOrdering`, `DefaultPolicy`, `RuleCounters`, `AddressSets`, +`Comments`, …). A false `RuleCounters`/`Comments` means `GetRules` simply reports +zero counters / an empty comment; every other false field means the corresponding +operation returns an unsupported error. Features every backend supports — ICMP and +ICMP-type matching, port ranges, source ports, and backup/restore — are not +advertised as booleans; a caller can rely on them unconditionally. The matrix +below still documents how each backend expresses these features. + +| Feature | firewalld | ufw | CSF | APF | iptables | nftables | pf | WFP | +| ---------------- | ------------- | --- | ------------ | ---------- | -------- | -------- | --------------------- | --- | +| Forward rules | no | yes (route) | via hook | via hook | yes | yes | no | no | +| ICMP | yes | yes | with address | type list | yes | yes | yes | yes | +| ICMPv6 | yes | yes | yes | yes | yes | yes | yes | yes | +| ICMP type | rich rule | yes | yes | yes | yes | yes | yes | yes | +| SCTP/GRE/ESP/AH | rich rule | yes | yes | yes | yes | yes | yes | by number (no SCTP port) | +| Comment | no | yes | yes | yes | yes | yes | label | description | +| Port range | yes | yes | yes | yes | yes | yes | yes | yes | +| Port list | no | yes | yes | ports config | yes | yes | yes | yes | +| Source port | yes | yes | adv rule | adv rule | yes | yes | yes | yes | +| Connection state | no | yes | yes | yes | yes | yes | no (stateful) | no | +| Interface match | no (zone) | yes | yes | yes | yes | yes | yes | no | +| Logging | rich rule | yes | yes | yes | yes | yes | yes (no prefix) | no | +| Rate limit | rich rule | yes | yes | yes | yes | yes | per-source | no | +| Connection limit | no | yes | per-port | per-port | yes | yes | per-source | no | +| NAT | fwd-port/masq | yes | dnat/redirect | dnat/snat/masq | yes | yes | rdr/nat (no redirect) | no | + +## Default policy + +`GetDefaultPolicy`/`SetDefaultPolicy` read and set the default action applied to +packets that match no rule. A `DefaultPolicy` carries an `Action` per direction +(`Input`, `Output`, `Forward`); a direction left as `ActionInvalid` is not +exposed (on `Get`) or left unchanged (on `Set`). On a backend that supports it, +the policy is captured in a `Backup` and re-asserted by `Restore`, so a snapshot +of a default-drop host reproduces that policy on replay rather than inheriting the +restore host's. + +| Backend | Directions supported | +| ------------ | ------------------------------- | +| iptables | input, output, forward | +| ufw | input, output, forward | +| nftables | input, output, forward | +| firewalld | input (the zone target) | +| others | unsupported (`ErrUnsupportedPolicy`) | + +## Address sets (ipset / nftset / pf tables) + +Address sets are named collections of addresses (`AddressSet`) that rules can +match against, managed separately from filter and NAT rules. A `Backup` captures +the managed sets (with their entries) and `Restore` recreates them before the +rules, so a set-referencing rule (`@set`) resolves when a snapshot is replayed on +a host that does not yet have the set. They map onto the backend's native +construct: + +| Backend | Construct | +| --------- | ---------------------------------- | +| iptables | ipset (`hash:ip`, `hash:net`) | +| ufw | ipset (via the host iptables) | +| nftables | a set in the private `inet` table | +| firewalld | a firewalld ipset (D-Bus) | +| pf | a pf table | +| CSF/APF/WFP | unsupported (`ErrUnsupportedSet`) | + +```go +set := &firewall.AddressSet{Name: "blocklist", Family: firewall.IPv4, Type: firewall.SetHashNet} +_ = mgr.AddAddressSet(ctx, set) +_ = mgr.AddAddressSetEntry(ctx, "blocklist", "203.0.113.0/24") +sets, _ := mgr.GetAddressSets(ctx) +``` + +## Testing + +```sh +go test ./... +``` + +The marshal/unmarshal (rule encoding/decoding) logic for each backend is unit +tested and does not require a live firewall. Backend detection and rule +application do require the corresponding firewall to be installed and running. diff --git a/apf_linux.go b/apf_linux.go new file mode 100644 index 0000000..352547c --- /dev/null +++ b/apf_linux.go @@ -0,0 +1,2196 @@ +package firewall + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "strconv" + "strings" + + dbus "github.com/coreos/go-systemd/dbus" +) + +const ( + APFType = "apf" + 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 is prepended to a rule's comment when it is written as a + // full-line comment above a rule in allow_hosts.rules/deny_hosts.rules, so + // rules this library creates can be told apart. conf.apf port/icmp-list + // rules carry no per-rule comment and so ignore it. + rulePrefix string + // ipv6Enabled mirrors conf.apf's USE_IPV6. apf's own shell logic silently + // 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) whenever USE_IPV6 is not "1" (the shipped default), + // so AddRule must reject those shapes rather than write a rule apf will + // never enforce. + ipv6Enabled bool +} + +// 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 + + // Connect to systemd dbus interface. + var prop *dbus.Property + conn, err := dbus.NewWithContext(ctx) + if err == nil { + defer conn.Close() + // Find the systemd service for apf and confirm it was loaded. + prop, _ = conn.GetUnitPropertyContext(ctx, "apf.service", "ActiveState") + } + + // If the service is not active in SystemD, check SysV status. + if prop == nil || prop.Value.Value() != "active" { + // Run SysV check to see if APF is enabled there. + results, err := runCommand(ctx, "chkconfig", "--list", "apf") + if err != nil { + return nil, fmt.Errorf("the apf service is not active on this server") + } + + // Parse the result to see if apf is on. + foundOn := false + for _, line := range results { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + + // If this is not the apf line, skip. + if fields[0] != "apf" { + continue + } + + // Parse the runlevel:status output to check if any are on. + for _, f := range fields { + _, status, found := strings.Cut(f, ":") + if found && status == "on" { + foundOn = true + } + } + } + + // If APF is not on, return result. + if !foundOn { + 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. +// Type returns the backend identifier for apf. +func (f *APF) Type() string { + return APFType +} + +// hook returns the managed pre-hook script used to inject iptables rules for +// features apf's native config cannot express. +// Capabilities reports the firewall features apf supports. +func (f *APF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + // ICMPv6 mirrors ipv6Enabled: apf's native IG_ICMPV6_TYPES/EG_ICMPV6_TYPES + // lists (what a plain, qualifier-free ICMPv6 rule uses) are a silent no-op + // on the real firewall whenever conf.apf's USE_IPV6 is not "1" (confirmed + // against a real apf install: ip6tables carries no rule for a type in + // IG_ICMPV6_TYPES when USE_IPV6=0). A rule that also needs state/interface/ + // log/rate matching still works via the raw-iptables hook regardless. + ICMPv6: f.ipv6Enabled, + PortList: false, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: false, + DefaultPolicy: false, + RuleCounters: false, + AddressSets: false, + Comments: true, + } +} + +// GetDefaultPolicy is unsupported: apf has no managed default-policy control. +// 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"). +// 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 +} + +// portToken renders a port spec in apf notation: a port or an underscore +// range. +// 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 +} + +// 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. +// 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, out bool) (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: directionFromOutput(out), 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 +} + +// splitAdvFields splits an apf advanced rule on ':' while leaving colons inside +// bracketed IPv6 addresses (e.g. [2001:db8::1]) intact. +// 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 +} + +// addrField renders an address for an advanced rule, wrapping an IPv6 address +// in brackets so it survives the colon-separated field format. +// 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 + } +} + +// 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. +// 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. path is a parameter (rather than always +// APFConf) so this can be exercised against a fixture file in tests. +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 +} + +// denyActionFor reads conf.apf's setting for the given protocol (see +// stopKey) and returns the action apf actually applies to a deny of that +// protocol. deny_hosts encodes no action of its own, so a rule read back must +// be stamped with what apf actually applies — otherwise a Drop rule reads back +// as Reject, never compares equal to the desired rule, and churns on every Sync. +// 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" + } +} + +// 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. path is a parameter (rather than always +// APFConf) so this can be exercised against a fixture file in tests. +// denyActionFor reads conf.apf's setting for the given protocol (see +// stopKey) and returns the action apf actually applies to a deny of that +// protocol. deny_hosts encodes no action of its own, so a rule read back must +// be stamped with what apf actually applies — otherwise a Drop rule reads back +// as Reject, never compares equal to the desired rule, and churns on every Sync. +func (f *APF) denyActionFor(proto Protocol) Action { + return f.readStopAction(APFConf, 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. +// 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: + return f.denyActionFor(proto) + default: + return base + } +} + +// ParseAdvRule decodes an apf advanced allow/deny rule of the form +// proto:flow:s/d=port:s/d=ip. IPv6 addresses use bracket notation. +// 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:]) +} + +// 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. +// 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}) + } + } + + // 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 +} + +// MarshalAdvRule encodes a rule as an apf advanced allow/deny line. An apf +// advanced rule is tcp/udp only and must carry a source or destination address. +// ParseIPList reads an apf allow_hosts/deny_hosts file and returns the rules it holds. +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 + } + + scanner := bufio.NewScanner(fd) + // A full-line comment immediately above a rule is that rule's comment. + // Consecutive comment lines accumulate (joined by a space); a blank line + // detaches a comment from any rule that follows. + var pendingComment string + flushComment := func() (string, bool) { + text, hasPrefix := prefixedComment(f.rulePrefix, pendingComment) + pendingComment = "" + return text, hasPrefix + } + for scanner.Scan() { + raw := scanner.Text() + trimmed := strings.TrimSpace(raw) + + // A full-line comment is held as a candidate rule comment. + if trimmed != "" && strings.HasPrefix(trimmed, "#") { + text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) + // A prefix tag starts a fresh comment block for the rule that + // follows, so header/section comments above it are not absorbed into + // the rule's comment and prefix detection stays reliable. + if f.rulePrefix != "" && (text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ")) { + pendingComment = text + } else if pendingComment != "" { + pendingComment += " " + text + } else { + pendingComment = text + } + continue + } + + // Strip an inline trailing comment (not a rule comment). + if ci := strings.IndexByte(trimmed, '#'); ci >= 0 { + trimmed = trimmed[:ci] + } + trimmed = strings.TrimSpace(trimmed) + + // A blank line detaches a pending comment from any later rule. + if len(trimmed) == 0 { + pendingComment = "" + continue + } + + comment, hasPrefix := flushComment() + + // An advanced rule carries s=/d= options; a bare line is a plain address + // (which may itself be an IPv6 address containing colons). + if strings.Contains(trimmed, "=") { + rule := f.ParseAdvRule(trimmed, action) + if rule == nil { + continue + } + rule.Comment = comment + rule.HasPrefix = hasPrefix + rules = append(rules, rule) + } else { + // Try to parse IP. + fam, ok := csfAddrFamily(trimmed) + if !ok { + continue + } + + // A plain IP line matches the host in both directions, so it is one + // bidirectional DirAny rule, authored in the inbound frame (Source=X). + rules = append(rules, &Rule{ + Direction: DirAny, + Family: fam, + Source: trimmed, + Action: action, + Comment: comment, + HasPrefix: hasPrefix, + }) + } + } + + _ = fd.Close() + if serr := scanner.Err(); serr != nil { + return nil, serr + } + return +} + +// 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. +// ParsePorts decodes an apf comma-separated port list into accept rules, one per port entry. +func (f *APF) ParsePorts(val string, proto Protocol, out bool) (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: directionFromOutput(out), + Action: Accept, + } + 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. +// 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 &hookScript{ + rulePrefix: f.rulePrefix, + hookPath: APFHook, + hookPerm: 0750, + } +} + +// GetZone returns no zone; apf has no zone support. +// 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, false)...) + case "IG_UDP_CPORTS": + rules = append(rules, f.ParsePorts(val, UDP, false)...) + case "EG_TCP_CPORTS": + rules = append(rules, f.ParsePorts(val, TCP, true)...) + case "EG_UDP_CPORTS": + rules = append(rules, f.ParsePorts(val, UDP, true)...) + case "IG_ICMP_TYPES": + rules = append(rules, f.ParseICMPTypes(val, ICMP, false)...) + case "EG_ICMP_TYPES": + rules = append(rules, f.ParseICMPTypes(val, ICMP, true)...) + case "IG_ICMPV6_TYPES": + rules = append(rules, f.ParseICMPTypes(val, ICMPv6, false)...) + case "EG_ICMPV6_TYPES": + rules = append(rules, f.ParseICMPTypes(val, ICMPv6, true)...) + 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...) + + // Merge rules across families so a v4/v6 hook pair collapses to one rule, then + // collapse each input/output twin into one DirAny rule — a plain allow_hosts/ + // deny_hosts IP, read as an inbound (source) rule plus an outbound (destination) + // rule, reads back as the single bidirectional line it was written as. + rules = mergeFamilies(rules) + rules = mergeDirections(rules) + return +} + +// 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. +// 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) +} + +// ParsePorts decodes an apf comma-separated port list into accept rules, one per port entry. +// 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`). +// 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))} +} + +// portTokens renders the rule's ports as apf config tokens. +// 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 +} + +// 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. +// 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. +// 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. + var wantTokens []string + switch key { + case "IG_TCP_CPORTS": + if r.IsOutput() || r.Proto != TCP { + return orig + } + wantTokens = f.portTokens(r) + case "IG_UDP_CPORTS": + if r.IsOutput() || r.Proto != UDP { + return orig + } + wantTokens = f.portTokens(r) + case "EG_TCP_CPORTS": + if !r.IsOutput() || r.Proto != TCP { + return orig + } + wantTokens = f.portTokens(r) + case "EG_UDP_CPORTS": + if !r.IsOutput() || 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. + // Three collections cooperate here: + // - want: the canonical form of every token this rule contributes, so a + // remove can recognize an existing token as "ours" regardless of + // how it was spelled in the file. + // - present: the canonical form of every token we decide to keep, so the + // add pass can tell whether one of the rule's tokens is already + // in the list and must not be appended a second time. + // - kept: the tokens, in original spelling, that survive into the + // rewritten list. This is the actual output. + // want and present are keyed by canonical form (comparison identity); kept + // holds the verbatim tokens (what we write back). + 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. Each token is either dropped (only + // on a remove, and only when it is one of the rule's own tokens) or kept. + // We keep the original spelling but track it by canonical form so pass 2 can + // dedupe against it. + 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. +// EditConf adds or removes a rule in conf.apf, rewriting the file in place. +func (f *APF) EditConf(ctx context.Context, r *Rule, remove bool) error { + // For port only rules, open the standard config file. + 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. + 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() +} + +// 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 — correct for csf, which has +// no native v6 type list — so 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. +// 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 +} + +// 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. +// MarshalAdvRule encodes a rule as an apf advanced allow/deny line. An apf +// advanced rule is tcp/udp only and must carry a source or destination address. +func (f *APF) MarshalAdvRule(r *Rule) (string, error) { + if r.Proto.IsICMP() { + return "", fmt.Errorf("apf advanced rules do not support icmp") + } + if r.Source == "" && r.Destination == "" { + return "", fmt.Errorf("an apf advanced rule requires a source or destination address") + } + // apf's advanced rule holds a single address field, so a rule matching both a + // source and a destination cannot be expressed; reject it rather than silently + // dropping the destination (which would install a broader rule than asked and + // leave the rule unremovable). Mirrors the dual-port guard below. + if r.Source != "" && r.Destination != "" { + return "", fmt.Errorf("apf advanced rules cannot match both a source and destination address") + } + // apf's port field takes a single port or an underscore range (no comma list), + // and there is a single port-flow field, so a rule cannot match both a source + // and a destination port at once. + if len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1 { + return "", fmt.Errorf("apf advanced rules do not support a port list in this model") + } + if r.HasPorts() && r.HasSourcePorts() { + return "", fmt.Errorf("apf advanced rules cannot match both a source and destination port") + } + + var parts []string + switch r.Proto { + case TCP: + parts = append(parts, "tcp") + case UDP: + parts = append(parts, "udp") + } + if r.IsOutput() { + parts = append(parts, "out") + } else { + parts = append(parts, "in") + } + // The port-flow field: a source port or a destination port. + 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, ":"), nil +} + +// ParseIPList reads an apf allow_hosts/deny_hosts file and returns the rules it holds. +// EditIPList adds or removes a rule in an apf allow_hosts/deny_hosts file, rewriting it in place. +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 + } + + // Stage the rewrite, preserving the list file's mode and ownership. + af, err := newAtomicFile(filePath, 0644) + if err != nil { + _ = fd.Close() + return err + } + defer af.Abort() + + scanner := bufio.NewScanner(fd) + exists := false + // 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) + + // pending holds the full-line comments seen immediately above a rule, so they + // can be dropped together with a removed rule (they are its comment) or written + // ahead of a kept one. A blank line detaches them. + var pending []string + flush := func() { + for _, c := range pending { + _, _ = fmt.Fprintln(af, c) + } + pending = nil + } + drop := func() { pending = nil } + + // Read the file line by line. + for scanner.Scan() { + orig := scanner.Text() + trimmed := strings.TrimSpace(orig) + + // A full-line comment is held as a candidate rule comment. + if trimmed != "" && strings.HasPrefix(trimmed, "#") { + // Mirror ParseIPList: a prefix tag starts a fresh comment block, so + // any header/section comments above it are not part of the rule's comment + // and must survive its removal. Flush them now and begin the rule's block + // at the tag, so drop() only discards the tag and the rule's own comment. + if f.rulePrefix != "" { + if text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")); text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ") { + flush() + } + } + pending = append(pending, orig) + continue + } + + // Strip an inline trailing comment for matching, but preserve the + // original line (with its inline note) when copying it through. + line := trimmed + if ci := strings.IndexByte(line, '#'); ci >= 0 { + line = line[:ci] + } + line = strings.TrimSpace(line) + + // A blank line detaches a pending comment; write it and the blank. + if len(line) == 0 { + flush() + _, _ = fmt.Fprintln(af, orig) + continue + } + + if strings.Contains(line, "=") { + rule := f.ParseAdvRule(line, action) + if rule == nil { + flush() + _, _ = fmt.Fprintln(af, orig) + continue + } + // Match family-aware: the address-less "port-only deny" form parses to + // Source="" for both families, so a bare EqualBase would let an IPv4 line + // stand in for its IPv6 twin. EqualForDedup/EqualForRemoval fold in the + // family coverage the add and remove paths respectively need. + var famMatch bool + if remove { + famMatch = rule.EqualForRemoval(&match, true) + } else { + famMatch = rule.EqualForDedup(&match, true) + } + if famMatch { + exists = true + if !remove { + flush() + _, _ = fmt.Fprintln(af, orig) + } else { + drop() + f.ConfigChanged = true + } + } else { + flush() + _, _ = fmt.Fprintln(af, orig) + } + } else { + // Try to parse IP. + fam, ok := csfAddrFamily(line) + if !ok { + flush() + _, _ = fmt.Fprintln(af, orig) + continue + } + + // A plain IP line is one bidirectional DirAny rule; match the target + // against it in the inbound frame (canonicalMatch), so a DirAny or a + // concrete-direction input/output target that names this host lines up. + plainRule := &Rule{ + Direction: DirAny, + Family: fam, + Source: line, + Action: action, + } + if plainRule.EqualBase(match.canonicalMatch(), false) { + exists = true + if !remove { + flush() + _, _ = fmt.Fprintln(af, orig) + } else { + drop() + f.ConfigChanged = true + } + } else { + flush() + _, _ = fmt.Fprintln(af, orig) + } + } + } + // Write any trailing comments that followed the last rule. + flush() + + // If not exists and not remove, try adding the rule. + if !exists && !remove { + writeComment := func() { + if c := combineComment(f.rulePrefix, r.Comment); c != "" { + _, _ = fmt.Fprintln(af, "# "+c) + } + } + hasIP := r.Source != "" || r.Destination != "" + switch { + case hasIP && (r.HasPorts() || r.HasSourcePorts()): + // A port rule with an address is an advanced rule. + line, err := f.MarshalAdvRule(r) + if err != nil { + _ = fd.Close() + return err + } + f.ConfigChanged = true + writeComment() + _, _ = fmt.Fprintln(af, line) + 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 (hostNeedsHook) 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. + f.ConfigChanged = true + writeComment() + if r.Source != "" { + _, _ = fmt.Fprintln(af, r.Source) + } else { + _, _ = fmt.Fprintln(af, r.Destination) + } + case action != Accept && r.HasPorts(): + // A port-only deny is written as an advanced rule against any address, + // which apf requires in the field position. apf's port field holds a + // single port or underscore range only; a multi-port list has no advanced- + // rule form, so AddRule diverts it to the hook (needsHook) and this + // branch only sees a single port/range. A direct caller supplying a list + // gets a best-effort single-port write. + specs := r.PortSpecs() + // apf requires an address in the field position, so a port-only deny + // uses the "any" network as a placeholder. That literal is family- + // specific (0.0.0.0/0 vs ::/0) and read back with its family, so emit + // the placeholder matching the rule's family. A family-neutral rule has + // no single literal, so cover both families — mergeFamilies collapses + // the v4/v6 pair back to FamilyAny on read, keeping the rule both + // readable and removable rather than silently becoming IPv4-only. + writeAny := func(placeholder string) { + var tokens []string + if r.Proto != ProtocolAny { + tokens = append(tokens, r.Proto.String()) + } + if r.IsOutput() { + tokens = append(tokens, "out") + } else { + tokens = append(tokens, "in") + } + tokens = append(tokens, "d="+f.portToken(specs[0])) + if r.IsOutput() { + tokens = append(tokens, "d="+placeholder) + } else { + tokens = append(tokens, "s="+placeholder) + } + writeComment() + _, _ = fmt.Fprintln(af, strings.Join(tokens, ":")) + } + switch r.impliedFamily() { + case IPv6: + writeAny("[::/0]") + case IPv4: + writeAny("0.0.0.0/0") + default: + writeAny("0.0.0.0/0") + writeAny("[::/0]") + } + f.ConfigChanged = true + } + } + + _ = 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() +} + +// removePlainHost drops the bidirectional plain allow_hosts/deny_hosts line backing +// the DirAny rule e, choosing the list by the rule's action. +// 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 +} + +// 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. +// 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 — correct for csf, which has +// no native v6 type list — so 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) +} + +// ipv6Unavailable reports whether adding r would silently write something +// apf itself never enforces: a bare IPv6 host in allow_hosts.rules/ +// deny_hosts.rules, or a native ICMPv6 type, are both no-op'd by apf's own +// shell logic when conf.apf's USE_IPV6 is not "1". A rule diverted to the +// raw-iptables hook is unaffected — the hook runs ip6tables directly, outside +// apf's USE_IPV6-gated logic — so this only applies to the two native paths. +// ipv6Unavailable reports whether adding r would silently write something +// apf itself never enforces: a bare IPv6 host in allow_hosts.rules/ +// deny_hosts.rules, or a native ICMPv6 type, are both no-op'd by apf's own +// shell logic when conf.apf's USE_IPV6 is not "1". A rule diverted to the +// raw-iptables hook is unaffected — the hook runs ip6tables directly, outside +// apf's USE_IPV6-gated logic — so this only applies to the two native paths. +func (f *APF) ipv6Unavailable(r *Rule) bool { + if f.ipv6Enabled { + return false + } + if f.nativeICMPv6(r) { + return true + } + return r.impliedFamily() == IPv6 && (r.Source != "" || r.Destination != "") +} + +// 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 that GetRules merged back from a v4+v6 hook pair). Both the add-time hook +// decision (dualStackPortNeedsHook) and the remove-time split/clear +// (removeDualStackPort) build on it. +// 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 that GetRules merged back from a v4+v6 hook pair). 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 (r.Proto == TCP || r.Proto == UDP) && 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 (unlike csf, whose TCP_IN/TCP6_IN split the +// families). A single-family one 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. +// 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 (unlike csf, whose TCP_IN/TCP6_IN split the +// families). A single-family one 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 shapes +// (ruleNeedsHook, bareHostOneWay, hostNeedsHook) and the two apf shapes RemoveRule +// reuses to route a split (dualStackPortNeedsHook, nativeICMPv6) keep their own +// predicates; the apf-only, single-use port/source-port/connlimit/icmp tests are +// inlined here as their sole caller. +// 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 shapes +// (ruleNeedsHook, bareHostOneWay, hostNeedsHook) and the two apf shapes RemoveRule +// reuses to route a split (dualStackPortNeedsHook, nativeICMPv6) keep their own +// predicates; the apf-only, single-use port/source-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 one-way bare host has no trust-file form (a plain line is bidirectional, an + // advanced rule needs a port), and a concrete-protocol host or a source+ + // destination pair likewise has none (see hostNeedsHook); all go to the hook. + if bareHostOneWay(r) || hostNeedsHook(r) { + return true + } + // A multi-port tcp/udp list apf's config cannot carry: its advanced rule holds a + // single port or one underscore range, and the only native multi-port shape is an + // address-less accept (isConfRule, carried by the IG_*_CPORTS comma lists); + // every other list goes to the hook's iptables multiport match. + if (r.Proto == TCP || r.Proto == UDP) && + (len(r.PortSpecs()) > 1 || len(r.SourcePortSpecs()) > 1) && !f.isConfRule(r) { + return true + } + // A source-port match with no address has no advanced-rule form (advanced rules + // require an address); iptables matches --sport directly, so it goes to the hook. + if r.HasSourcePorts() && r.Source == "" && r.Destination == "" && + (r.Proto == TCP || r.Proto == UDP) { + 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 bare protocol match with no address and no port has no native apf construct — + // the trust files key on an address and conf.apf's lists on a port or icmp type — + // but iptables expresses it directly, so it goes to the hook (see + // bareProtoNeedsHook). A native address-less ICMP/ICMPv6 accept is excluded there; + // connection limits and every other addressed/ported shape are routed above. + if bareProtoNeedsHook(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) +} + +// EditIPList adds or removes a rule in an apf allow_hosts/deny_hosts file, rewriting it in place. +// 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 { + // 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 + } + + // 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 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 + } + if enforceIPv6Gate && f.ipv6Unavailable(r) { + return fmt.Errorf("apf's IPv6 handling is disabled (conf.apf USE_IPV6 is not \"1\"): %w", ErrUnsupported) + } + if err := iptablesRuleValid(r); err != nil { + return fmt.Errorf("%v: %w", err, ErrUnsupported) + } + + // 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. +// 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) +} + +// RemoveRule removes a rule from apf, routing it to the config file or pre-hook that holds it. +// 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()) +} + +// InsertNATRule is unsupported: APF stores NAT in a config file it applies as a +// whole, with no explicit ordering. +// 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()) +} + +// Capabilities reports the firewall features apf supports. +// 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) +} + +// 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 — not the merged rule view, which 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. +// 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.IsAny() || !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 { + 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 +} + +// 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. +// 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 "" +} + +// natFile returns the routing file a NAT rule belongs in: source NAT is +// applied in POSTROUTING (postroute.rules), destination NAT in PREROUTING +// (preroute.rules). +// 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 — not the merged rule view, which 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 { + val, err := readConfValue(APFConf, f.cPortsKey(r.Proto, r.IsOutput())) + if err != nil { + return err + } + inCPorts := false + for _, e := range f.ParsePorts(val, r.Proto, r.IsOutput()) { + if e.EqualForRemoval(r, true) { + inCPorts = true + break + } + } + 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()) + 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 that GetRules merged +// back into one FamilyAny rule), or split across both. The merged 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 the whole merged rule wherever it lives. Unlike the single-family +// removeDualStackPort there is no untargeted family to preserve, so nothing is re-added. +// 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 that GetRules merged +// back into one FamilyAny rule), or split across both. The merged 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 the whole merged rule wherever it lives. Unlike the single-family +// removeDualStackPort there is no untargeted family to preserve, so nothing is re-added. +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 +} + +// GetRules returns the current filter rules read from conf.apf, the allow/deny lists, and the pre-hook. +// 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 + } + + // 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 GetRules merged back), 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 + } + if err := iptablesRuleValid(r); err != nil { + return fmt.Errorf("%v: %w", err, ErrUnsupported) + } + + // 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) +} + +// Backup captures the current filter and NAT rules managed by this backend. +// parseNATLine decodes a raw iptables nat command line back into a NATRule, +// reporting whether the line is one this backend recognizes. +func (f *APF) parseNATLine(line string) (*NATRule, bool) { + line = strings.TrimSpace(line) + var fam Family + var rest string + switch { + case strings.HasPrefix(line, "iptables -t nat "): + fam, rest = IPv4, strings.TrimPrefix(line, "iptables -t nat ") + case strings.HasPrefix(line, "ip6tables -t nat "): + fam, rest = IPv6, strings.TrimPrefix(line, "ip6tables -t nat ") + default: + return nil, false + } + ipt := &IPTables{rulePrefix: f.rulePrefix} + r, err := ipt.UnmarshalNATRule(rest, fam) + if err != nil { + return nil, false + } + return r, true +} + +// parseNATFile reads a routing-rules file and returns the NAT rules it holds. +// parseNATFile reads a routing-rules file and returns the NAT rules it holds. +func (f *APF) parseNATFile(path string) ([]*NATRule, error) { + fd, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = fd.Close() }() + + var rules []*NATRule + 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, ok := f.parseNATLine(line); ok { + rules = append(rules, r) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return rules, nil +} + +// GetNATRules returns the NAT rules held in apf's preroute and postroute routing files. +// GetNATRules returns the NAT rules held in apf's preroute and postroute routing files. +func (f *APF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) { + var rules []*NATRule + for _, path := range []string{APFPreroute, APFPostroute} { + parsed, err := f.parseNATFile(path) + 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). + merged := mergeNATFamilies(rules) + return merged, nil +} + +// editNATFile adds or removes a NAT rule's command line(s) in a routing file. +// A family-agnostic rule occupies one line per family; both are added or dropped +// together. It records whether a reload is needed. +// natFamilies lists the address families a NAT rule is written for: a rule +// pinned to a family touches only that command; a family-agnostic rule (e.g. a +// portless masquerade) is written for both v4 and v6. +func (f *APF) natFamilies(r *NATRule) []Family { + switch r.impliedFamily() { + case IPv4: + return []Family{IPv4} + case IPv6: + return []Family{IPv6} + default: + return []Family{IPv4, IPv6} + } +} + +// natLine encodes a NAT rule as a raw iptables/ip6tables nat-table command +// line for the given family, the form apf's shell-sourced routing files expect. +// 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 +} + +// natCommand returns the iptables command name for a family. +// natCommand returns the iptables command name for a family. +func (f *APF) natCommand(fam Family) string { + if fam == IPv6 { + return "ip6tables" + } + return "iptables" +} + +// natFamilies lists the address families a NAT rule is written for: a rule +// pinned to a family touches only that command; a family-agnostic rule (e.g. a +// portless masquerade) is written for both v4 and v6. +// natLine encodes a NAT rule as a raw iptables/ip6tables nat-table command +// line for the given family, the form apf's shell-sourced routing files expect. +func (f *APF) natLine(r *NATRule, fam Family) (string, error) { + ipt := &IPTables{rulePrefix: f.rulePrefix} + rc := *r + rc.Family = fam + spec, err := ipt.MarshalNATRule(&rc) + if err != nil { + return "", err + } + return f.natCommand(fam) + " -t nat " + spec, nil +} + +// parseNATLine decodes a raw iptables nat command line back into a NATRule, +// reporting whether the line is one this backend recognizes. +// editNATFile adds or removes a NAT rule's command line(s) in a routing file. +// A family-agnostic rule occupies one line per family; both are added or dropped +// together. It records whether a reload is needed. +func (f *APF) editNATFile(r *NATRule, remove bool) error { + path := f.natFile(r) + + // The line(s) this rule contributes, one per family it targets. + want := make(map[string]bool) + for _, fam := range f.natFamilies(r) { + line, err := f.natLine(r, fam) + if err != nil { + return err + } + want[line] = true + } + + data, err := os.ReadFile(path) + 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)+len(want)) + present := make(map[string]bool) + changed := false + for _, raw := range lines { + body := raw + if ci := strings.IndexByte(body, '#'); ci >= 0 { + body = body[:ci] + } + body = strings.TrimSpace(body) + // Match either the exact line we would write or an equivalent NAT rule. On a + // match, record which of our want-lines this existing line satisfies so the + // add path below does not append a duplicate: an exact match satisfies its own + // text; a fuzzy (equivalent) match satisfies the want-line for the same family. + matched := false + satisfied := "" + if want[body] { + matched, satisfied = true, body + } else if body != "" { + // The fuzzy fallback (an equivalent line whose text differs from ours) + // must stay family-aware (EqualForRemoval): a family-agnostic rule fans + // into a v4 and a v6 line in the same direction file, so without the gate + // a family-scoped removal would also drop the opposite family's twin. + if existing, ok := f.parseNATLine(body); ok && existing.EqualForRemoval(r) { + matched = true + // The equivalent want-line is the one for this existing line's family; + // mark it satisfied so its duplicate is not appended below. Recompute it + // (rather than reuse body) since body is the existing spelling, not ours. + if line, lerr := f.natLine(r, existing.impliedFamily()); lerr == nil { + satisfied = line + } + } + } + if matched { + if remove { + changed = true + continue + } + if satisfied != "" { + present[satisfied] = true + } + } + out = append(out, raw) + } + + if remove { + if !changed { + return nil + } + } else { + added := false + for line := range want { + if !present[line] { + out = append(out, line) + added = true + } + } + if !added { + return nil + } + } + + content := strings.Join(out, "\n") + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + if err := writeConfigFile(path, []byte(content), 0600); err != nil { + return err + } + f.ConfigChanged = true + return nil +} + +// AddNATRule adds a NAT rule to apf's preroute or postroute routing file. +// 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 + } + return f.editNATFile(r, false) +} + +// RemoveNATRule removes a NAT rule from apf's preroute or postroute routing file. +// 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()) +} + +// MoveRule is unsupported for the same reason as InsertRule. +// 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 + } + return f.editNATFile(r, true) +} + +// 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). +// 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; Restore removes the + // current rules and re-adds these, so every rule read is preserved. + return &Backup{Rules: rules, NATRules: natRules}, nil +} + +// Restore replaces the managed rules with the contents of a Backup. +// 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 + } + } + + // 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. +// 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. +// 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 is unsupported: apf has no address-set support. +// GetAddressSets is unsupported: apf has no address-set support. +func (f *APF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + return nil, unsupportedSet(f.Type()) +} + +// GetAddressSet is unsupported: apf has no address-set support. +// GetAddressSet is unsupported: apf has no address-set support. +func (f *APF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + return nil, unsupportedSet(f.Type()) +} + +// AddAddressSet is unsupported: apf has no address-set support. +// AddAddressSet is unsupported: apf has no address-set support. +func (f *APF) AddAddressSet(ctx context.Context, set *AddressSet) error { + return unsupportedSet(f.Type()) +} + +// RemoveAddressSet is unsupported: apf has no address-set support. +// RemoveAddressSet is unsupported: apf has no address-set support. +func (f *APF) RemoveAddressSet(ctx context.Context, name string) error { + return unsupportedSet(f.Type()) +} + +// AddAddressSetEntry is unsupported: apf has no address-set support. +// AddAddressSetEntry is unsupported: apf has no address-set support. +func (f *APF) AddAddressSetEntry(ctx context.Context, name, entry string) error { + return unsupportedSet(f.Type()) +} + +// RemoveAddressSetEntry is unsupported: apf has no address-set support. +// RemoveAddressSetEntry is unsupported: apf has no address-set support. +func (f *APF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + return unsupportedSet(f.Type()) +} + +// 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. + if f.ConfigChanged { + _, err := runCommand(ctx, "/etc/apf/apf", "--restart") + if err != nil { + return err + } + } + return nil +} + +// Close releases resources held by the manager; apf holds none. +// Close releases resources held by the manager; apf holds none. +func (f *APF) Close(ctx context.Context) error { + return nil +} + +// InsertRule is unsupported: APF organizes rules in config files, not an ordered list. diff --git a/apf_linux_test.go b/apf_linux_test.go new file mode 100644 index 0000000..c571917 --- /dev/null +++ b/apf_linux_test.go @@ -0,0 +1,819 @@ +package firewall + +import ( + "context" + "os" + "path/filepath" + "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, err := fw.MarshalAdvRule(c.rule) + require.NoError(t, err, "failed to marshal %+v", *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) + + // MarshalAdvRule rejects rules apf cannot express as advanced rules. + bad := []*Rule{ + {Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept}, + {Proto: TCP, Port: 80, Action: Accept}, + {Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Action: Accept}, + } + for _, r := range bad { + _, err := fw.MarshalAdvRule(r) + require.Error(t, err, "expected error marshalling %+v", *r) + } +} + +// 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. Before the fix it keyed on Reject, so a Drop deny wrote nothing +// while AddRule reported success — the port stayed 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, err := fw.MarshalAdvRule(c.rule) + require.NoError(t, err, "failed to marshal %+v", *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) + } + + // Matching both a source and a destination port is not representable. + _, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}) + require.Error(t, err, "expected error matching both source and destination ports") + + // A source-port rule with no address has no advanced-rule form, so it routes to + // the hook; one carrying an address stays on the native advanced-rule path. + require.True(t, fw.needsHook(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}), + "an address-less source-port rule must route to the hook") + require.False(t, fw.needsHook(&Rule{Proto: TCP, SourcePort: 1234, Source: "1.2.3.4", Action: Accept})) +} + +// TestAPFDualStackPort locks in that apf routes a single-family bare port accept to +// the hook: its CPORTS lists are dual-stack and express only a FamilyAny port, so a +// concrete-family one is written per-family through the hook. A FamilyAny port stays +// native, and a port with an address or an ICMP type takes another path. +func TestAPFDualStackPort(t *testing.T) { + fw := new(APF) + // A concrete-family bare TCP/UDP port accept goes to the hook. + require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCP, Port: 3492, Action: Accept})) + require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv6, Proto: UDP, Port: 3492, Action: Accept})) + + // A dual-stack (FamilyAny) port is what the shared CPORTS list represents. + require.False(t, fw.dualStackPortNeedsHook(&Rule{Proto: TCP, Port: 3492, Action: Accept})) + // A port with an address is a host rule, and ICMP has no port — neither is a + // dual-stack CPORTS accept. + require.False(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCP, Port: 3492, Source: "192.0.2.1", Action: Accept})) + require.False(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept})) +} + +// TestAPFBarePortAccept locks in the removal routing shared by single-family and +// FamilyAny bare tcp/udp port accepts. A FamilyAny port merged back from a v4+v6 hook +// pair reads as impliedFamily FamilyAny, so dualStackPortNeedsHook (single-family +// only) does not match it; barePortAccept must, so RemoveRule routes it to the +// family-agnostic removeFamilyAnyPort instead of the native-only EditConf path that +// cannot clear the hook rows. Regression for mergedfamilyremove leaving the port open. +func TestAPFBarePortAccept(t *testing.T) { + fw := new(APF) + // Both a concrete-family and a FamilyAny bare port accept are bare-port accepts; + // only the concrete-family one additionally forces the add-time hook. + v4 := &Rule{Family: IPv4, Proto: TCP, Port: 3492, Action: Accept} + any := &Rule{Family: FamilyAny, Proto: TCP, Port: 3492, Action: Accept} + require.True(t, fw.barePortAccept(v4)) + require.True(t, fw.barePortAccept(any)) + require.True(t, fw.dualStackPortNeedsHook(v4)) + require.False(t, fw.dualStackPortNeedsHook(any), "a FamilyAny port must route to removeFamilyAnyPort, not the single-family split") + + // A deny, an address-bearing rule, a source-port-only match (no CPORTS form), and + // a non-tcp/udp match are not bare-port accepts and take other removal paths. + require.False(t, fw.barePortAccept(&Rule{Proto: TCP, Port: 3492, Action: Drop})) + require.False(t, fw.barePortAccept(&Rule{Proto: TCP, Port: 3492, Source: "192.0.2.1", Action: Accept})) + require.False(t, fw.barePortAccept(&Rule{Proto: TCP, SourcePort: 3492, Action: Accept})) + require.False(t, fw.barePortAccept(&Rule{Proto: ICMP, Action: Accept})) +} + +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) + + // Only a single inbound tcp/udp port|range, address-less, per-source, reject + // rule maps onto conf.apf natively; every other connection limit routes to the + // hook rather than being rejected. + native := &Rule{Proto: UDP, Port: 53, Action: Reject, ConnLimit: &ConnLimit{Count: 100, PerSource: true}} + require.True(t, fw.isConnLimitRule(native)) + require.False(t, fw.needsHook(native), "a native connlimit stays in conf.apf") + hooked := []*Rule{ + {Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // non-reject action + {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // proto + {Proto: TCP, Port: 80, Source: "1.2.3.4", Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // address + {Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}, // global + } + for _, r := range hooked { + require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r) + } +} + +func TestAPFNATRoundTrip(t *testing.T) { + fw := new(APF) + cases := []*NATRule{ + {Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}, + {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"}, + {Kind: Masquerade, Family: IPv4, Interface: "eth1"}, + } + for _, orig := range cases { + for _, fam := range fw.natFamilies(orig) { + line, err := fw.natLine(orig, fam) + require.NoError(t, err, "marshal %+v", *orig) + got, ok := fw.parseNATLine(line) + require.True(t, ok, "failed to parse %q", line) + require.True(t, got.EqualBase(orig), "line %q: want %+v got %+v", line, orig, got) + } + } + + // The command line targets the right binary and nat chain. + dnat, err := fw.natLine(&NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}, IPv4) + require.NoError(t, err) + require.Contains(t, dnat, "iptables -t nat -A PREROUTING") + require.Equal(t, APFPreroute, fw.natFile(&NATRule{Kind: DNAT})) + require.Equal(t, APFPostroute, fw.natFile(&NATRule{Kind: Masquerade})) + + masq6, err := fw.natLine(&NATRule{Kind: Masquerade, Family: IPv6, Interface: "eth1"}, IPv6) + require.NoError(t, err) + require.Contains(t, masq6, "ip6tables -t nat -A POSTROUTING") + + // A non-NAT line is ignored by the parser. + _, ok := fw.parseNATLine("# place your custom routing rules below") + require.False(t, ok) +} + +func TestAPFNATHasPrefixFlag(t *testing.T) { + r := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} + + // With a prefix configured, the emitted iptables line carries the prefix + // comment tag, and parsing it back reports HasPrefix — APF's NAT rules are + // real iptables commands, so the same comment mechanism as the filter rules + // applies. + withPrefix := &APF{rulePrefix: "myapp"} + line, err := withPrefix.natLine(r, IPv4) + require.NoError(t, err) + require.Contains(t, line, `-m comment --comment "myapp"`) + got, ok := withPrefix.parseNATLine(line) + require.True(t, ok) + require.True(t, got.HasPrefix, "an APF NAT rule tagged with the prefix reports HasPrefix") + + // With no prefix no tag is written, so HasPrefix stays false. + noPrefix := &APF{} + line, err = noPrefix.natLine(r, IPv4) + require.NoError(t, err) + got, ok = noPrefix.parseNATLine(line) + require.True(t, ok) + require.False(t, got.HasPrefix, "with no prefix an APF NAT rule reports no HasPrefix") +} + +func TestAPFPortAndICMPConfig(t *testing.T) { + fw := new(APF) + + // Port lists parse single ports and underscore ranges. + rules := fw.ParsePorts("21,22,6000_7000", TCP, false) + 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, false) + 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") +} + +func TestAPFICMPRouting(t *testing.T) { + fw := new(APF) + // An ICMPv4 rule apf's IG_ICMP_TYPES list cannot express — one carrying an + // address or a non-accept action — routes to the hook. + require.True(t, fw.needsHook(&Rule{Proto: ICMP, Source: "1.2.3.4", ICMPType: Ptr[uint8](8), Action: Accept})) + require.True(t, fw.needsHook(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Drop})) + + // The ICMPv6 equivalents route to the hook via ruleNeedsHook (every ICMPv6 rule + // does) since they are not a native type list entry (nativeICMPv6). + for _, r := range []*Rule{ + {Proto: ICMPv6, Source: "2001:db8::1", ICMPType: Ptr[uint8](128), Action: Accept}, + {Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Drop}, + } { + require.True(t, ruleNeedsHook(r) && !fw.nativeICMPv6(r), "expected hook routing for %+v", *r) + } + + // Valid inbound allows stay native: an ICMP type, an ICMPv6 type, and a typeless + // "all" allow are address-less accepts (isConfRule), not hook rules. + for _, r := range []*Rule{ + {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, + {Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, + {Proto: ICMP, Action: Accept}, + {Proto: ICMPv6, Action: Accept}, + } { + require.False(t, fw.needsHook(r), "%+v must stay native", *r) + require.True(t, fw.isConfRule(r), "%+v must be a conf.apf rule", *r) + if r.Proto == ICMPv6 { + require.True(t, fw.nativeICMPv6(r), "%+v must be a native ICMPv6 type", *r) + } + } +} + +// 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, false) + 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, true) + 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})) +} + +// apf's own shell logic (apf_trust.sh trust_hosts(), cports.common) silently +// no-ops a bare IPv6 host and the native ICMPv6 type lists whenever conf.apf's +// USE_IPV6 is not "1" (the shipped default). ipv6Unavailable must flag +// exactly those two native shapes, and only when ipv6Enabled is false; a rule +// diverted to the raw-iptables hook (which bypasses USE_IPV6 entirely) must +// never be flagged. +func TestAPFIPv6UnavailableGate(t *testing.T) { + disabled := &APF{ipv6Enabled: false} + enabled := &APF{ipv6Enabled: true} + + nativeICMPv6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept} + require.True(t, disabled.ipv6Unavailable(nativeICMPv6), + "a native ICMPv6 type rule must be blocked when USE_IPV6 is off") + require.False(t, enabled.ipv6Unavailable(nativeICMPv6), + "a native ICMPv6 type rule must be allowed when USE_IPV6 is on") + + bareV6Host := &Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept} + require.True(t, disabled.ipv6Unavailable(bareV6Host), + "a bare IPv6 host rule must be blocked when USE_IPV6 is off") + require.False(t, enabled.ipv6Unavailable(bareV6Host), + "a bare IPv6 host rule must be allowed when USE_IPV6 is on") + + bareV4Host := &Rule{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept} + require.False(t, disabled.ipv6Unavailable(bareV4Host), + "an IPv4 rule must never be blocked by the IPv6 gate") + + // An ICMPv6 rule that also needs state matching is diverted to the + // raw-iptables hook (see nativeICMPv6), which runs outside apf's + // USE_IPV6-gated shell logic, so it must not be blocked either way. + hookRoutedICMPv6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept} + require.False(t, disabled.ipv6Unavailable(hookRoutedICMPv6), + "a hook-routed ICMPv6 rule must not be blocked by the IPv6 gate") +} + +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)) +} + +// Before this fix, a tcp/udp advanced deny_hosts entry (a port-carrying rule) was +// stamped/matched with the ALL_STOP-derived action for every entry, including +// advanced ones. Upstream apf actually routes an advanced entry through +// trust_entry_rule, which ignores ALL_STOP entirely and applies TCP_STOP/UDP_STOP +// instead (files/internals/apf_trust.sh) — invisible on a stock install where all +// three default to DROP, but wrong whenever an operator sets them differently. +// resolveAction must re-derive the action for a tcp/udp rule rather than +// passing the ALL_STOP-derived base straight through; a bare (protocol-less) +// rule has no such override and keeps the base unchanged. +func TestAPFResolveActionDoesNotConflateAllStopWithProtocolStops(t *testing.T) { + fw := new(APF) + // A bare-address deny has no protocol; ALL_STOP governs it directly. + require.Equal(t, Reject, fw.resolveAction(Reject, ProtocolAny)) + + // A tcp/udp advanced deny must NOT simply inherit an ALL_STOP-derived base. + // With no real conf.apf in this test environment, TCP_STOP/UDP_STOP resolve + // to their stock default (Drop) — which must differ from the Reject base + // passed in to prove the value was actually re-derived, not passed through. + require.Equal(t, Drop, fw.resolveAction(Reject, TCP)) + require.Equal(t, Drop, fw.resolveAction(Reject, UDP)) + + // Accept (allow_hosts) has no per-protocol distinction. + require.Equal(t, Accept, fw.resolveAction(Accept, TCP)) +} + +// AddRule's deny-action validation must check a tcp/udp advanced rule against +// TCP_STOP/UDP_STOP, not ALL_STOP: before this fix, a Reject deny with a tcp port +// was rejected as "unsupported" whenever the (irrelevant) ALL_STOP-derived action +// differed from Reject, even though nothing about ALL_STOP governs this entry. +func TestAPFAddRuleValidatesDenyActionPerProtocol(t *testing.T) { + ctx := context.Background() + fw := &APF{} + + dir := t.TempDir() + deny := filepath.Join(dir, "deny_hosts.rules") + require.NoError(t, os.WriteFile(deny, nil, 0o644)) + + // AddRule itself always targets the real APFDeny path, so exercise the same + // validation + write path it uses (denyActionFor + EditIPList) directly + // against a fixture, mirroring AddRule's own logic. + r := &Rule{Action: Drop, Proto: TCP, Port: 8080, Source: "192.0.2.1", Family: IPv4} + denyAction := fw.denyActionFor(r.Proto) + require.Equal(t, Drop, denyAction, "stock/no-conf.apf default for TCP_STOP is Drop") + require.Equal(t, r.Action, denyAction, "a Drop tcp deny must be accepted (matches the TCP_STOP-derived default)") + require.NoError(t, fw.EditIPList(ctx, deny, denyAction, r, false)) + + got, err := fw.ParseIPList(deny, denyAction) + require.NoError(t, err) + require.Len(t, got, 1) + require.True(t, r.Equal(got[0], false)) +} + +// apf's deny_hosts list encodes no action of its own, so a rule added with +// Action Drop (routed to deny_hosts) must still be found and removed by the same +// Drop rule — previously the removal matched against the file's deny action and +// silently left the rule in place (an unremovable, leaking rule). +func TestAPFDropRuleRemovable(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "deny_hosts.rules") + require.NoError(t, os.WriteFile(path, nil, 0644)) + fw := new(APF) + drop := &Rule{Family: IPv4, Source: "1.2.3.4", Action: Drop} + + require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false)) + // A second add of the same rule must be idempotent (no duplicate line). + 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") +} + +// A ported apf advanced rule that matches both a source and a destination address +// cannot be expressed (apf's advanced rule holds a single address field) and must +// be rejected rather than silently dropping the destination. The portless bare +// form is not tested here: AddRule diverts it to the raw-iptables hook +// (hostNeedsHook), so the writer never sees it. +func TestAPFDualAddressRejected(t *testing.T) { + fw := new(APF) + _, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 22, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}) + require.Error(t, err, "a dual-address apf advanced rule must be rejected") +} + +// 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, false) + 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") +} + +// TestAPFBareProtocolRoutesToHook is the apf analogue of the csf case: a bare +// protocol match (no port, no address) has no native apf construct but iptables +// expresses it directly, so AddRule diverts it to the pre-hook via needsHook +// rather than rejecting it. A native address-less ICMP accept stays out of the +// hook (it lives in conf.apf's type lists). +func TestAPFBareProtocolRoutesToHook(t *testing.T) { + fw := new(APF) + for _, r := range []*Rule{ + {Proto: TCP, Action: Accept}, + {Proto: ProtocolAny, Action: Accept}, + {Proto: UDP, Action: Drop}, + } { + require.True(t, fw.needsHook(r), + "a portless, addressless rule must route to the hook, not be rejected: %+v", r) + } + require.False(t, fw.needsHook(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}), + "a native address-less ICMP accept stays in conf.apf, not the hook") +} + +// A port-only deny must not corrupt the rule's family. apf requires an address +// field, so it writes an "any" placeholder — previously the IPv4 literal +// 0.0.0.0/0 regardless of family, so a family-neutral rule read back as IPv4 +// (Sync churn) and an IPv6 rule became IPv4-only. The placeholder now matches the +// rule's family (a family-neutral rule covers both, merging back on read). +// +// 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) { + fw := new(APF) + 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)) + + // GetRules applies mergeFamilies to the parsed list; mirror it here. + got, err := fw.ParseIPList(deny, Drop) + require.NoError(t, err) + got = mergeFamilies(got) + require.Len(t, got, 1, "port-only deny (%s) must round-trip to one rule", rule.Family) + require.True(t, rule.Equal(got[0], false), "read-back rule must equal the written one; want family=%s got family=%s", rule.Family, got[0].Family) + + // 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 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 (hostNeedsHook) 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") +} + +// needsHook selects the multi-port lists apf's config cannot express, so +// AddRule diverts them to the hook. A single port or one range is a valid apf +// port token (stays native); an address-less tcp/udp accept list lives in +// conf.apf's CPORTS lists; a non-tcp/udp match is left to iptablesRuleValid. +func TestAPFPortNeedsHook(t *testing.T) { + fw := new(APF) + list := []PortRange{{Start: 1000, End: 1000}, {Start: 2000, End: 2000}} + cases := []struct { + name string + rule *Rule + want bool + }{ + {"multi-port deny", &Rule{Proto: TCP, Ports: list, Action: Reject}, true}, + {"multi-port host accept", &Rule{Proto: TCP, Ports: list, Source: "1.2.3.4", Action: Accept}, true}, + {"multi-source-port host", &Rule{Proto: UDP, SourcePorts: list, Source: "1.2.3.4", Action: Accept}, true}, + {"address-less accept list", &Rule{Proto: TCP, Ports: list, Action: Accept}, false}, + {"single port deny", &Rule{Proto: TCP, Port: 1000, Action: Reject}, false}, + {"single range host", &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Source: "1.2.3.4", Action: Accept}, false}, + {"multi-port any-proto", &Rule{Ports: list, Action: Reject}, false}, + } + for _, c := range cases { + require.Equal(t, c.want, fw.needsHook(c.rule), c.name) + } +} + +// APF EditIPList must write the missing IPv6 line when adding the IPv6 twin of an +// existing IPv4 port-only deny; the family-agnostic EqualBase check previously +// treated the IPv4 line as covering IPv6 and wrote 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") +} diff --git a/backup.go b/backup.go new file mode 100644 index 0000000..c485ef2 --- /dev/null +++ b/backup.go @@ -0,0 +1,275 @@ +package firewall + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" +) + +// This file makes a Backup portable: it can be serialized to and from JSON so a +// snapshot taken on one host can be replayed on another (or persisted to disk). +// +// The rule/NAT-rule structs are plain data, but their enum fields (Action, +// Family, Protocol, ...) are iota-based uint8 values. Left to encoding/json +// those would marshal as bare numbers, which are unreadable and would silently +// change meaning if a constant were ever reordered. They are therefore given +// MarshalJSON/UnmarshalJSON implementations that carry the canonical, stable +// string name each type's String method already emits. The two generic helpers +// below keep that boilerplate to one line per type. + +// 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. +func (k *NATKind) UnmarshalJSON(data []byte) error { + v, err := unmarshalEnum(data, ParseNATKind) + 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. A genuine read error would already have + // surfaced from the GetRules call the Backup makes before this, which reads the + // same source; leaving the field nil then makes Restore leave the policy as it + // finds it. + if policy, err := mgr.GetDefaultPolicy(ctx, zoneName); 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-repopulate (ipset -exist, pfctl -T add). +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/capabilities_linux_test.go b/capabilities_linux_test.go new file mode 100644 index 0000000..4927abb --- /dev/null +++ b/capabilities_linux_test.go @@ -0,0 +1,120 @@ +package firewall + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCSFAPFStillUnsupported(t *testing.T) { + ctx := context.Background() + csf := &CSF{} + apf := &APF{} + + // Logging, rate limiting, connection-state and interface matching are now + // expressed by injecting iptables rules through the pre-hook (see + // TestHookScriptRoundTrip), so those route to the hook rather than being + // rejected. What remains genuinely unsupported: address sets and explicit + // rule ordering on both backends, plus source NAT on CSF. + for _, m := range []Manager{csf, apf} { + require.ErrorIs(t, m.AddAddressSet(ctx, &AddressSet{Name: "x"}), ErrUnsupportedSet, + "%s should reject address sets", m.Type()) + require.ErrorIs(t, m.InsertRule(ctx, "", 1, &Rule{Port: 22, Proto: TCP, Action: Accept}), ErrUnsupportedOrdering, + "%s should reject explicit ordering", m.Type()) + // NAT ordering is unsupported on both even though they store NAT rules. + dnat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} + require.ErrorIs(t, m.InsertNATRule(ctx, "", 1, dnat), ErrUnsupportedOrdering, + "%s should reject NAT ordering", m.Type()) + } + + // csf.redirect expresses only destination NAT, so a source NAT is rejected + // with the NAT sentinel before any file is touched. + snat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"} + require.ErrorIs(t, csf.AddNATRule(ctx, "", snat), ErrUnsupportedNAT, + "csf should reject source NAT") +} + +// Every unsupported-feature path wraps a sentinel error that callers can match +// with errors.Is. The umbrella ErrUnsupported matches them all. +func TestSentinelErrors(t *testing.T) { + ctx := context.Background() + csf := &CSF{} + + // NAT, policy and address-set rejections carry their specific sentinel. CSF + // supports destination NAT (csf.redirect) but not source NAT, so a SNAT rule + // carries the NAT sentinel. + nat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"} + err := csf.AddNATRule(ctx, "", nat) + require.ErrorIs(t, err, ErrUnsupportedNAT) + require.ErrorIs(t, err, ErrUnsupported) + + _, err = csf.GetDefaultPolicy(ctx, "") + require.ErrorIs(t, err, ErrUnsupportedPolicy) + + err = csf.AddAddressSet(ctx, &AddressSet{Name: "x"}) + require.ErrorIs(t, err, ErrUnsupportedSet) + + // The shared per-rule reject helper wraps sentinels too (used by the wf backend). + err = (&Rule{Proto: TCP, Port: 22, Action: Accept, Log: true}).rejectLogAndLimit("csf") + require.ErrorIs(t, err, ErrUnsupportedLog) + require.ErrorIs(t, err, ErrUnsupported) +} + +// Capabilities advertise each backend's supported features consistently with +// its actual behavior. +func TestCapabilities(t *testing.T) { + nft := &NFT{} + ipt := &IPTables{} + csf := &CSF{} + apf := &APF{} + + require.True(t, nft.Capabilities().RuleCounters, "nftables exposes rule counters") + require.True(t, nft.Capabilities().DefaultPolicy, "nftables manages a default policy") + require.True(t, nft.Capabilities().AddressSets, "nftables manages address sets") + require.True(t, nft.Capabilities().NAT) + + require.True(t, ipt.Capabilities().RuleCounters, "iptables exposes rule counters") + require.True(t, ipt.Capabilities().DefaultPolicy) + require.True(t, ipt.Capabilities().AddressSets, "iptables manages ipsets") + + // CSF is a deliberately minimal backend: no counters, no policy, no sets. + require.False(t, csf.Capabilities().RuleCounters) + require.False(t, csf.Capabilities().DefaultPolicy) + require.False(t, csf.Capabilities().AddressSets) + // CSF does express (destination) NAT through csf.redirect and per-port + // connection limiting through CONNLIMIT. + require.True(t, csf.Capabilities().NAT) + require.True(t, csf.Capabilities().ConnLimit) + + // APF likewise gains NAT (routing files) and connection limiting + // (IG_*_CLIMIT) from its native config, but still no address sets. + require.True(t, apf.Capabilities().NAT) + require.True(t, apf.Capabilities().ConnLimit) + require.False(t, apf.Capabilities().AddressSets) + + // Both gain logging, rate limiting, connection-state, interface matching and + // forward-chain rules by injecting iptables rules through the pre-hook. + for _, c := range []Capabilities{csf.Capabilities(), apf.Capabilities()} { + require.True(t, c.Logging) + require.True(t, c.RateLimit) + require.True(t, c.ConnState) + require.True(t, c.InterfaceMatch) + require.True(t, c.Forward) + } + + // nftables, iptables and ufw express forward-chain rules natively. + require.True(t, nft.Capabilities().Forward, "nftables expresses forward rules") + require.True(t, ipt.Capabilities().Forward, "iptables expresses forward rules") + + // CSF's ICMPv6 always goes through that same pre-hook (raw ip6tables), so it + // is unconditionally supported regardless of csf.conf's IPV6 setting. + require.True(t, csf.Capabilities().ICMPv6) + + // APF's plain ICMPv6 rules instead use its native IG_ICMPV6_TYPES/ + // EG_ICMPV6_TYPES lists, which apf itself silently no-ops unless conf.apf's + // USE_IPV6 is "1" — so the capability mirrors ipv6Enabled. + require.False(t, apf.Capabilities().ICMPv6, "USE_IPV6 not confirmed enabled, so ICMPv6 must not be advertised") + apfV6 := &APF{ipv6Enabled: true} + require.True(t, apfV6.Capabilities().ICMPv6) +} diff --git a/cmd/go-firewall/backup.go b/cmd/go-firewall/backup.go new file mode 100644 index 0000000..3dd8d55 --- /dev/null +++ b/cmd/go-firewall/backup.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "fmt" + "os" + + fw "github.com/grmrgecko/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..3dd4935 --- /dev/null +++ b/cmd/go-firewall/cli_test.go @@ -0,0 +1,317 @@ +package main + +import ( + "strings" + "testing" + + fw "github.com/grmrgecko/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..5f8c7be --- /dev/null +++ b/cmd/go-firewall/go.mod @@ -0,0 +1,34 @@ +module github.com/grmrgecko/firewall/cmd/go-firewall + +go 1.26.4 + +require ( + github.com/alecthomas/kong v1.15.0 + github.com/grmrgecko/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 v0.0.0-20191104093116-d3cd4ed1dbcf // 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/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/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 + go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf // indirect + golang.org/x/sys v0.40.0 // indirect +) + +replace github.com/grmrgecko/firewall => ../.. + +// Mirrors the library's own replace: the old coreos/go-systemd import path is +// redirected to the maintained v22 module (the v0 release does not compile +// against modern godbus). Kept here so the CLI is self-contained when built. +replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0 diff --git a/cmd/go-firewall/go.sum b/cmd/go-firewall/go.sum new file mode 100644 index 0000000..e1d9c37 --- /dev/null +++ b/cmd/go-firewall/go.sum @@ -0,0 +1,143 @@ +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/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/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/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/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/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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.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..7377b4e --- /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/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..da771ec --- /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/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/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..7af6d80 --- /dev/null +++ b/cmd/go-firewall/nat.go @@ -0,0 +1,216 @@ +package main + +import ( + "context" + "fmt" + "os" + + fw "github.com/grmrgecko/firewall" +) + +// This file holds the NAT command group: list, add, 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."` + 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") +} + +// 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..f9e063f --- /dev/null +++ b/cmd/go-firewall/output.go @@ -0,0 +1,239 @@ +package main + +import ( + "fmt" + "io" + "strconv" + "strings" + + fw "github.com/grmrgecko/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..27698e1 --- /dev/null +++ b/cmd/go-firewall/parse.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + fw "github.com/grmrgecko/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..d1dc855 --- /dev/null +++ b/cmd/go-firewall/policy.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "fmt" + + fw "github.com/grmrgecko/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..3f1d5cd --- /dev/null +++ b/cmd/go-firewall/rule.go @@ -0,0 +1,350 @@ +package main + +import ( + "context" + "fmt" + "os" + + fw "github.com/grmrgecko/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..51bd628 --- /dev/null +++ b/cmd/go-firewall/set.go @@ -0,0 +1,189 @@ +package main + +import ( + "context" + "fmt" + "os" + + fw "github.com/grmrgecko/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/configfile.go b/configfile.go new file mode 100644 index 0000000..7b04fba --- /dev/null +++ b/configfile.go @@ -0,0 +1,118 @@ +package firewall + +import ( + "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. +type atomicFile struct { + *os.File + 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. +func newAtomicFile(dst string, defaultMode os.FileMode) (*atomicFile, error) { + 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, + dst: dst, + tmp: fd.Name(), + mode: mode, + uid: uid, + gid: gid, + chown: chown, + }, nil +} + +// 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 + } + // Apply mode and ownership through the fd so the staged file is never + // briefly installed with the wrong permissions. + if err := a.File.Chmod(a.mode); err != nil { + return a.fail(err) + } + if a.chown { + if err := a.File.Chown(a.uid, a.gid); err != nil && !errors.Is(err, os.ErrPermission) { + return a.fail(err) + } + } + if err := a.File.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.File.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.File.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/configfile_other.go b/configfile_other.go new file mode 100644 index 0000000..3d95714 --- /dev/null +++ b/configfile_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/configfile_test.go b/configfile_test.go new file mode 100644 index 0000000..e603036 --- /dev/null +++ b/configfile_test.go @@ -0,0 +1,157 @@ +//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() +} diff --git a/configfile_unix.go b/configfile_unix.go new file mode 100644 index 0000000..b59b038 --- /dev/null +++ b/configfile_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/csf_linux.go b/csf_linux.go new file mode 100644 index 0000000..64bdf0b --- /dev/null +++ b/csf_linux.go @@ -0,0 +1,1947 @@ +package firewall + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "strconv" + "strings" + "time" + + dbus "github.com/coreos/go-systemd/dbus" +) + +const ( + CSFType = "csf" + 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 is prepended to a rule's comment when it is written as a + // full-line comment above a rule in csf.allow/csf.deny, so rules this + // library creates can be told apart. csf.conf port-list rules carry no + // per-rule comment and so ignore it. + rulePrefix string + // ipv6Enabled mirrors csf.conf's IPV6. csf.pl's linefilter silently drops a + // csf.allow/csf.deny line (plain or advanced) that resolves to an IPv6 + // address whenever IPV6 is not "1" (the shipped default), so AddRule must + // reject that shape rather than write a line csf will never enforce. + // ICMPv6 is unaffected: it always routes through the raw-iptables hook, + // which runs outside csf's own IPV6-gated logic. + 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 + + // Connect to systemd dbus interface. + conn, err := dbus.NewWithContext(ctx) + if err != nil { + return nil, fmt.Errorf("failed to connect to systemd: %s", err) + } + defer conn.Close() + + // Find the systemd service for csf and confirm it is set to start. CSF's + // installer detects a running systemd at install time and drops in a real + // static unit file, giving "enabled"/"enabled-runtime" — but when systemd + // isn't PID 1 at install time (e.g. a container image built without an init + // process) the installer falls back to its SysV /etc/init.d/csf script, and + // systemd's sysv-generator synthesizes a wrapper unit at boot, which reports + // as "generated" (confirmed against a real csf install: FragmentPath under + // /run/systemd/generator*, SourcePath /etc/init.d/csf). Both are a + // legitimately enabled csf.service, so both are accepted here. + prop, err := conn.GetUnitPropertyContext(ctx, "csf.service", "UnitFileState") + if err != nil { + return nil, fmt.Errorf("error getting csf service property: %s", err) + } + switch prop.Value.Value() { + case "enabled", "enabled-runtime", "generated": + // csf.service is present and set to start (native unit or SysV-backed). + default: + 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 its 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 the new csf object. + return csf, nil +} + +// Type reports the backend identifier, "csf". +func (f *CSF) Type() string { + return CSFType +} + +// 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 &hookScript{ + rulePrefix: f.rulePrefix, + hookPath: CSFHook, + hookPerm: 0700, + } +} + +// 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 +} + +// 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) +} + +// 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, ",") +} + +// 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 +} + +// 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, out bool) (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: directionFromOutput(out), + Action: Accept, + } + portSpecsToRule(rule, []PortRange{pr}) + rules = append(rules, rule) + } + return +} + +// csfAddrFamily parses an address (IP or CIDR) and reports its family, or false +// when the value is not a valid address. +func csfAddrFamily(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 +} + +// 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 := csfAddrFamily(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 +} + +// 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 +} + +// 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 +} + +// 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 + } + } + + return +} + +// MarshalAdvRule encodes a rule as a csf advanced allow/deny line. A csf +// advanced rule must carry a source or destination address. +func (f *CSF) MarshalAdvRule(r *Rule) (string, error) { + if r.Proto == ICMPv6 { + return "", fmt.Errorf("csf advanced rules do not support icmpv6") + } + if r.Source == "" && r.Destination == "" { + return "", fmt.Errorf("a csf advanced rule requires a source or destination address") + } + // A csf advanced rule holds a single address field, so a rule matching both a + // source and a destination cannot be expressed; reject it rather than silently + // dropping the destination (installing a broader rule and leaving it + // unremovable). Mirrors the dual-port guard below. + if r.Source != "" && r.Destination != "" { + return "", fmt.Errorf("csf advanced rules cannot match both a source and destination address") + } + // A csf advanced rule carries a single port-flow field, so a rule cannot match + // both a source and a destination port at once. + if r.HasPorts() && r.HasSourcePorts() { + return "", fmt.Errorf("csf advanced rules cannot match both a source and destination port") + } + // An ICMP advanced rule needs a concrete type for the port-flow field: without + // one the address would land there and csf would parse it as `--icmp-type ` + // and drop the rule (see checkICMP). Refuse to emit such a line. + if r.Proto == ICMP && r.ICMPType == nil { + return "", fmt.Errorf("a csf icmp advanced rule requires a concrete icmp type") + } + // A protocol-bearing port rule needs a concrete tcp/udp: an address+port rule + // with ProtocolAny emits a protocol-less advanced line, and csf.pl's linefilter + // defaults that to `-p tcp`, so the rule would silently apply to TCP only while + // the library reads it back as ProtocolAny — leaving UDP open on a deny (or + // unallowed on an accept). The port-only form has no address here (it takes the + // placeholder path, which fans ProtocolAny to tcp+udp); the address form cannot + // fan within a single advanced line, so reject it rather than under-apply it. + if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) { + return "", fmt.Errorf("csf advanced rules require a concrete tcp or udp protocol for a port match with an address: %w", ErrUnsupported) + } + // csf.pl's linefilter reads an advanced line by fixed field position, not by + // tag: it always looks for the port-flow field (d=/s=) before the address + // field. A tcp/udp rule with an address but no port shifts the address into + // the port-flow slot instead, where it is parsed as a garbage --sport/--dport + // value and the address field is left empty — linefilter then requires both + // an address and a port to install anything, so the rule is silently dropped. + if r.Proto != ICMP && !r.HasPorts() && !r.HasSourcePorts() { + return "", fmt.Errorf("a csf tcp/udp advanced rule with an address requires a port: %w", ErrUnsupported) + } + + 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, "|"), nil +} + +// ParseIPList reads a csf.allow/csf.deny file into rules, stamping each with the +// given action and any full-line comment that precedes it. +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 + } + + scanner := bufio.NewScanner(fd) + // A full-line comment immediately above a rule is that rule's comment. + // Consecutive comment lines accumulate (joined by a space); a blank line + // detaches a comment from any rule that follows. + var pendingComment string + flushComment := func() (string, bool) { + text, hasPrefix := prefixedComment(f.rulePrefix, pendingComment) + pendingComment = "" + return text, hasPrefix + } + for scanner.Scan() { + raw := scanner.Text() + trimmed := strings.TrimSpace(raw) + + // A full-line comment is held as a candidate rule comment. + if trimmed != "" && strings.HasPrefix(trimmed, "#") { + text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) + // A prefix tag starts a fresh comment block for the rule that + // follows, so header/section comments above it are not absorbed into + // the rule's comment and prefix detection stays reliable. + if f.rulePrefix != "" && (text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ")) { + pendingComment = text + } else if pendingComment != "" { + pendingComment += " " + text + } else { + pendingComment = text + } + continue + } + + // Strip an inline trailing comment (not a rule comment). + if ci := strings.IndexByte(trimmed, '#'); ci >= 0 { + trimmed = trimmed[:ci] + } + trimmed = strings.TrimSpace(trimmed) + + // A blank line detaches a pending comment from any later rule. + if len(trimmed) == 0 { + pendingComment = "" + continue + } + + comment, hasPrefix := flushComment() + + if strings.Contains(trimmed, "|") { + rule := f.ParseAdvRule(trimmed, action) + if rule == nil { + continue + } + rule.Comment = comment + rule.HasPrefix = hasPrefix + rules = append(rules, rule) + } else { + // Try to parse IP. + family, ok := csfAddrFamily(trimmed) + if !ok { + continue + } + + // A plain IP line matches the host in both directions, so it is one + // bidirectional DirAny rule, authored in the inbound frame (Source=X). + rules = append(rules, &Rule{ + Direction: DirAny, + Family: family, + Source: trimmed, + Action: action, + Comment: comment, + HasPrefix: hasPrefix, + }) + } + } + + _ = fd.Close() + if serr := scanner.Err(); serr != nil { + return nil, serr + } + return +} + +// 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, false)...) + case "TCP_OUT": + rules = append(rules, f.ParsePorts(val, IPv4, TCP, true)...) + case "UDP_IN": + rules = append(rules, f.ParsePorts(val, IPv4, UDP, false)...) + case "UDP_OUT": + rules = append(rules, f.ParsePorts(val, IPv4, UDP, true)...) + case "TCP6_IN": + rules = append(rules, f.ParsePorts(val, IPv6, TCP, false)...) + case "TCP6_OUT": + rules = append(rules, f.ParsePorts(val, IPv6, TCP, true)...) + case "UDP6_IN": + rules = append(rules, f.ParsePorts(val, IPv6, UDP, false)...) + case "UDP6_OUT": + rules = append(rules, f.ParsePorts(val, IPv6, UDP, true)...) + 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. + 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...) + + // Merge rules across families, then across protocol: a ProtocolAny port rule is + // stored as a tcp line plus a udp line (see the fan-out in EditIPList), so + // collapse such a pair back to one ProtocolAny rule or it never reads back as + // what was written and churns on every reconcile. + rules = mergeFamilies(rules) + rules = f.mergeProtocols(rules) + // Collapse each input/output twin into one DirAny rule — in particular a plain + // csf.allow/csf.deny IP, read as an inbound (source) rule plus an outbound + // (destination) rule, reads back as the single bidirectional line it was written + // as. Runs after the family/protocol merges so the twin is already canonicalized. + rules = mergeDirections(rules) + return +} + +// mergeProtocols collapses a TCP rule and a UDP rule that are otherwise +// identical into one ProtocolAny rule — the inverse of the tcp+udp fan-out +// EditIPList writes for a ProtocolAny port rule. Without it a ProtocolAny rule +// never reads back as the rule that was written, so it is re-added on every +// reconcile and can never be matched for removal. +func (f *CSF) mergeProtocols(rules []*Rule) []*Rule { + for i := 0; i < len(rules); i++ { + if rules[i].Proto != TCP && rules[i].Proto != UDP { + continue + } + for b := i + 1; b < len(rules); b++ { + if rules[b].Proto != TCP && rules[b].Proto != UDP { + continue + } + if rules[i].Proto == rules[b].Proto { + continue + } + // Only collapse a same-family pair: CSF expresses IPv4 and IPv6 through + // separate config keys (TCP_IN vs TCP6_IN), so a tcp/v4 and udp/v6 pair + // cover different families and merging them would drop one family's + // coverage. mergeFamilies has already collapsed genuine v4/v6 twins, so + // the only pairs left to merge here are same-family. + if rules[i].impliedFamily() != rules[b].impliedFamily() { + continue + } + // Compare ignoring protocol: a tcp/udp pair equal in every other field is + // the fanned-out form of one ProtocolAny rule. + a, c := *rules[i], *rules[b] + a.Proto, c.Proto = ProtocolAny, ProtocolAny + if a.EqualBase(&c, true) { + rules[i].Proto = ProtocolAny + rules = append(rules[:b], rules[b+1:]...) + b-- + } + } + } + return rules +} + +// advMatch reports whether a parsed advanced-rule line (the existing row) +// matches target. A ProtocolAny port rule is written as a separate tcp line and +// udp line, so a ProtocolAny target matches either concrete-protocol line: the +// parsed line's protocol is normalized to match before comparison. The family +// coverage the add and remove paths need is folded into EqualForDedup / +// EqualForRemoval. +func (f *CSF) advMatch(parsed, target *Rule, remove bool) bool { + p := parsed + if target.Proto == ProtocolAny && (parsed.Proto == TCP || parsed.Proto == UDP) { + q := *parsed + q.Proto = ProtocolAny + p = &q + } + if remove { + return p.EqualForRemoval(target, true) + } + return p.EqualForDedup(target, true) +} + +// portOnlyDenyLines returns the advanced csf.deny lines a port-only deny (no +// address) fans out to: one per transport (tcp and udp for a ProtocolAny rule) and +// per family placeholder (0.0.0.0/0 for IPv4, ::/0 for IPv6, both for a +// family-neutral rule). It mirrors the emit path so the add logic can compare +// against the lines already in the file and write only the missing ones — a single +// "exists" flag would skip the whole fan-out when just one family/protocol line was +// already present, leaving the other family/protocol open. +func (f *CSF) portOnlyDenyLines(r *Rule) []string { + dir := "in" + if r.IsOutput() { + dir = "out" + } + port := f.advPortValue(r.PortSpecs()) + protos := []string{r.Proto.String()} + if r.Proto == ProtocolAny { + protos = []string{"tcp", "udp"} + } + var placeholders []string + switch r.impliedFamily() { + case IPv6: + placeholders = []string{"::/0"} + case IPv4: + placeholders = []string{"0.0.0.0/0"} + default: + placeholders = []string{"0.0.0.0/0", "::/0"} + } + var lines []string + for _, ph := range placeholders { + for _, p := range protos { + tokens := []string{p, dir, "d=" + port} + if r.IsOutput() { + tokens = append(tokens, "d="+ph) + } else { + tokens = append(tokens, "s="+ph) + } + lines = append(lines, strings.Join(tokens, "|")) + } + } + return lines +} + +// 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 + } + case "TCP6_OUT": + if !r.IsOutput() || r.Family == IPv4 || r.Proto == UDP { + return orig + } + case "UDP6_IN": + if r.IsOutput() || r.Family == IPv4 || r.Proto == TCP { + return orig + } + case "UDP6_OUT": + if !r.IsOutput() || r.Family == IPv4 || r.Proto == TCP { + 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 { + // For port only rules, open the standard config file. + 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() +} + +// EditIPList rewrites a csf.allow/csf.deny file to add or remove a rule, +// carrying its full-line comment with it. +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 + } + + // Stage the rewrite, preserving the list file's mode and ownership. + af, err := newAtomicFile(filePath, 0644) + if err != nil { + _ = fd.Close() + return err + } + defer af.Abort() + + scanner := bufio.NewScanner(fd) + exists := false + // A port-only deny (no address) fans out into several csf.deny lines across + // family and protocol. Track which of those lines are already in the file so the + // add path can write only the missing ones: the single "exists" flag below marks + // the whole rule present as soon as any one fan-out line matches, which would + // otherwise leave the other family/protocol open. wantDeny is empty for every + // other rule shape, so this tracking is inert unless the fan-out applies. + wantDeny := map[string]bool{} + if !remove && action != Accept && r.HasPorts() && r.Source == "" && r.Destination == "" { + for _, l := range f.portOnlyDenyLines(r) { + wantDeny[l] = true + } + } + presentDeny := map[string]bool{} + // 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 + // Full-line comments immediately above a rule are held back so they can be + // dropped together with a removed rule (they are its comment) and written + // ahead of a kept one. A blank line detaches them. + var pending []string + flush := func() { + for _, c := range pending { + _, _ = fmt.Fprintln(af, c) + } + pending = nil + } + drop := func() { pending = nil } + for scanner.Scan() { + orig := scanner.Text() + trimmed := strings.TrimSpace(orig) + + // A full-line comment is held as a candidate rule comment. + if trimmed != "" && strings.HasPrefix(trimmed, "#") { + // Mirror ParseIPList: a prefix tag starts a fresh comment block, so + // any header/section comments above it are not part of the rule's comment + // and must survive its removal. Flush them now and begin the rule's block + // at the tag, so drop() only discards the tag and the rule's own comment. + if f.rulePrefix != "" { + if text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")); text == f.rulePrefix || strings.HasPrefix(text, f.rulePrefix+" ") { + flush() + } + } + pending = append(pending, orig) + continue + } + + // Strip an inline trailing comment for matching, but preserve the + // original line (with its inline note) when copying it through. + line := trimmed + if ci := strings.IndexByte(line, '#'); ci >= 0 { + line = line[:ci] + } + line = strings.TrimSpace(line) + + // A blank line detaches a pending comment; write it and the blank. + if len(line) == 0 { + flush() + _, _ = fmt.Fprintln(af, orig) + continue + } + + // Note a fan-out line that is already present so the add path below skips it + // and writes only the missing family/protocol lines. Every such line is + // preserved (written back) by the pass-through branches below. + if wantDeny[line] { + presentDeny[line] = true + } + + if strings.Contains(line, "|") { + rule := f.ParseAdvRule(line, action) + if rule == nil { + flush() + _, _ = fmt.Fprintln(af, orig) + continue + } + if f.advMatch(rule, &match, remove) { + exists = true + if !remove { + flush() + _, _ = fmt.Fprintln(af, orig) + } else { + drop() + } + } else { + flush() + _, _ = fmt.Fprintln(af, orig) + } + } else { + // Try to parse IP. + family, ok := csfAddrFamily(line) + if !ok { + flush() + _, _ = fmt.Fprintln(af, orig) + continue + } + + // A plain IP line is one bidirectional DirAny rule; match the target + // against it in the inbound frame (canonicalMatch), so a DirAny or a + // concrete-direction input/output target that names this host lines up. + plainRule := &Rule{ + Direction: DirAny, + Family: family, + Source: line, + Action: action, + } + if plainRule.EqualBase(match.canonicalMatch(), false) { + exists = true + if !remove { + flush() + _, _ = fmt.Fprintln(af, orig) + } else { + drop() + } + } else { + flush() + _, _ = fmt.Fprintln(af, orig) + } + } + } + // Write any trailing comments that followed the last rule. + flush() + + // If not exists and not remove, try adding the rule. + if !exists && !remove { + writeComment := func() { + if c := combineComment(f.rulePrefix, r.Comment); c != "" { + _, _ = fmt.Fprintln(af, "# "+c) + } + } + hasIP := r.Source != "" || r.Destination != "" + switch { + case hasIP && (r.HasPorts() || r.HasSourcePorts() || r.Proto.IsICMP()): + // A port/ICMP rule with an address is an advanced rule. + line, err := f.MarshalAdvRule(r) + if err != nil { + _ = fd.Close() + return err + } + writeComment() + _, _ = fmt.Fprintln(af, line) + 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 (hostNeedsHook) 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. + writeComment() + if r.Source != "" { + _, _ = fmt.Fprintln(af, r.Source) + } else { + _, _ = fmt.Fprintln(af, r.Destination) + } + } + } + + // A port-only deny (no address) fans out into a csf.deny line per family and + // protocol. Unlike the single-line cases above it must NOT be gated on the + // whole-rule "exists" flag: when only a subset of the fan-out lines is already + // present (e.g. the IPv4 line but not the IPv6 one, from a prior single-family + // add or a manual edit), the missing lines must still be written or that + // family/protocol stays open while the library reports the port blocked. Emit + // only the lines not already present (presentDeny, noted during the scan). + // + // csf's advanced-rule handler only emits an iptables rule when the line carries + // an address, so each fan-out line uses the "any" network placeholder (0.0.0.0/0 + // or ::/0) as the address; parseAddr normalizes it back to an empty address + // and mergeFamilies collapses the v4/v6 pair to FamilyAny on read, keeping the + // rule readable and removable. The transport is named explicitly (a + // protocol-less line defaults to tcp in csf's linefilter), and a ProtocolAny + // deny fans to both tcp and udp. + if len(wantDeny) > 0 { + writeComment := func() { + if c := combineComment(f.rulePrefix, r.Comment); c != "" { + _, _ = fmt.Fprintln(af, "# "+c) + } + } + for _, line := range f.portOnlyDenyLines(r) { + if presentDeny[line] { + continue + } + writeComment() + _, _ = fmt.Fprintln(af, line) + } + } + + _ = 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() +} + +// 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 +} + +// checkConnLimit rejects a connection-limit request csf's CONNLIMIT cannot +// express, so an inexpressible one is reported rather than silently dropped. +func (f *CSF) checkConnLimit(r *Rule) error { + if r.ConnLimit == nil || f.isConnLimitRule(r) { + return nil + } + return fmt.Errorf("csf connection limiting (CONNLIMIT) applies only to a single inbound tcp port with no address, per source, rejecting the excess with a tcp reset: %w", ErrUnsupportedConnLimit) +} + +// 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 +} + +// 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, ",")) +} + +// checkSourcePort rejects a source-port match csf cannot express. csf source +// ports live in an advanced rule, which requires an address, so a source-port +// rule without one has nowhere to go. +func (f *CSF) checkSourcePort(r *Rule) error { + if r.HasSourcePorts() && r.Source == "" && r.Destination == "" { + return fmt.Errorf("a csf source-port rule requires a source or destination address: %w", ErrUnsupportedSourcePort) + } + return nil +} + +// checkPortProto rejects a port match on a concrete protocol csf cannot +// express as a port. csf's port lists are TCP_IN/UDP_IN only, so a port on a +// concrete non-tcp/udp protocol (e.g. sctp) would otherwise be wrongly written +// into BOTH the TCP and UDP lists. ProtocolAny is allowed: an address-less +// accept maps to both lists (the faithful "any" expansion) and a port-only +// reject to a protocol-less csf.deny advanced rule. +func (f *CSF) checkPortProto(r *Rule) error { + switch r.Proto { + case TCP, UDP, ProtocolAny: + return nil + } + if r.HasPorts() || r.HasSourcePorts() { + return fmt.Errorf("csf requires a tcp or udp protocol for a port match: %w", ErrUnsupported) + } + return nil +} + +// checkICMP rejects ICMP rules csf cannot express: csf advanced rules are +// built on iptables ICMP (IPv4) and require an address, and must also carry a +// concrete type: csf's linefilter treats the single port-flow field as the +// icmp-type, so an address with no type would put the address in that field +// (`--icmp-type `), which csf then fails to parse and drops silently (csf.pl +// linefilter). There is no csf advanced-rule encoding for "any icmp type from an +// address" — a bare host rule already covers all protocols — so reject it rather +// than emit a dropped line. ICMPv6 never reaches this check: ruleNeedsHook routes +// it to the pre-hook before addRule/RemoveRule call checkICMP. +func (f *CSF) checkICMP(r *Rule) error { + if r.Proto == ICMP { + if r.Source == "" && r.Destination == "" { + return fmt.Errorf("a csf icmp rule requires a source or destination address: %w", ErrUnsupported) + } + if r.ICMPType == nil { + return fmt.Errorf("a csf icmp rule with an address requires a concrete icmp type: %w", ErrUnsupported) + } + } + return nil +} + +// ipv6Unavailable reports whether adding r would silently write a +// csf.allow/csf.deny line (plain or advanced) that csf.pl's linefilter drops: +// any line resolving to an IPv6 address is dropped whenever csf.conf's IPV6 is +// not "1". ICMPv6 is unaffected — it always routes through the raw-iptables +// hook (see ruleNeedsHook), never csf's own IPV6-gated logic. Every other rule +// that reaches the native csf path (past the hook branch in addRule) with an +// implied IPv6 family is written as a v6-resolving line — whether the family +// comes from a v6 address or from a port-only deny/allow whose "any" address is +// synthesized as ::/0 (portOnlyDenyLines) — so the gate keys on the implied +// family alone. +func (f *CSF) ipv6Unavailable(r *Rule) bool { + return !f.ipv6Enabled && r.Proto != ICMPv6 && r.impliedFamily() == IPv6 +} + +// 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) +} + +// 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 { + // 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 + } + + // Features csf's native config cannot express (connection-state, per-rule + // interface, logging, rate limiting, icmpv6) are injected as iptables rules + // through the csf pre-hook. + if ruleNeedsHook(r) { + _, err := f.hook().edit(r, false) + return err + } + // A one-way bare host allow/deny cannot be a plain csf line (bidirectional) nor + // an advanced rule (needs a port), so it is written to the hook. A DirAny bare + // host takes the plain-line path below. + if bareHostOneWay(r) { + _, err := f.hook().edit(r, false) + return err + } + // A concrete-protocol host or a source+destination pair likewise has no csf.allow/ + // csf.deny form (see hostNeedsHook), so it too is injected as a raw iptables rule. + if hostNeedsHook(r) { + _, err := f.hook().edit(r, false) + return err + } + // A bare protocol match with no address and no port has no native csf construct — + // csf.conf keys on a port, an advanced rule on address+port, and csf.allow/csf.deny + // on an address — but iptables expresses it directly, so it is injected through the + // pre-hook (see bareProtoNeedsHook). It is checked ahead of the IPv6 gate because + // the hook runs ip6tables directly, outside csf.conf's IPV6-gated logic. ICMP keeps + // its own handling (checkICMP) and is excluded there. + if bareProtoNeedsHook(r) { + _, err := f.hook().edit(r, false) + return err + } + if enforceIPv6Gate && f.ipv6Unavailable(r) { + return fmt.Errorf("csf's IPv6 handling is disabled (csf.conf IPV6 is not \"1\"): %w", ErrUnsupported) + } + if err := f.checkSourcePort(r); err != nil { + return err + } + if err := f.checkConnLimit(r); err != nil { + return err + } + if err := f.checkICMP(r); err != nil { + return err + } + if err := f.checkPortProto(r); err != nil { + return err + } + + // A connection-limit rule maps onto the csf.conf CONNLIMIT list. + 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. + if r.Source == "" && r.Destination == "" && r.HasPorts() && r.Action == Accept { + err := f.EditConf(ctx, r, false) + 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 + // (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 +} + +// 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 + } + + // Hook-injected rules (see AddRule) are removed from the managed script. + if ruleNeedsHook(r) { + _, err := f.hook().edit(r, true) + 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 concrete-protocol host or source+destination pair is stored only as its own + // hook rule (it has no plain-line form to split), so remove it directly. + if hostNeedsHook(r) { + _, err := f.hook().edit(r, true) + return err + } + // A bare protocol match is stored only as its own hook rule (see AddRule), so + // remove it from the managed script directly. ICMP keeps its own handling below. + if bareProtoNeedsHook(r) { + _, err := f.hook().edit(r, true) + return err + } + if err := f.checkSourcePort(r); err != nil { + return err + } + if err := f.checkConnLimit(r); err != nil { + return err + } + if err := f.checkICMP(r); err != nil { + return err + } + if err := f.checkPortProto(r); err != nil { + return err + } + + // This rule is natively expressible, but a copy of it may nonetheless live in the + // hook — 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. Clear any hook copy before removing the native entry, so a rule + // present in both csf's config and the hook is removed from both and a hook-only + // deny is fully removed here. DirAny is expanded so both one-way hook lines are + // matched; a rule with no hook copy makes this a harmless no-op. + for _, sub := range expandDirections(r) { + if _, err := f.hook().edit(sub, true); err != nil { + return err + } + } + + // A 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 deny whose + // action differs from csf.conf's action for its direction was only ever in the hook + // (cleared above), so return rather than match it against csf.deny — where it could + // coincide with a genuine matching-action entry. A matching-action deny lives in + // csf.deny. + if r.Action == Accept { + err := f.EditIPList(ctx, CSFAllow, Accept, r, true) + if err != nil { + return err + } + } else { + denyAction := f.denyAction(r.IsOutput()) + if r.Action != denyAction { + return nil + } + err := f.EditIPList(ctx, CSFDeny, denyAction, r, true) + if err != nil { + return err + } + } + return nil +} + +// 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.IsAny() || !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 { + _, 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 +} + +// 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) +} + +// 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 +} + +// 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()) +} + +// 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()) +} + +// 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()) +} + +// 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; Restore removes the + // current rules and re-adds these, so every rule read is preserved. + return &Backup{Rules: rules, NATRules: natRules}, 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 + } + } + + // 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 +} + +// 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) +} + +// 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 +} + +// MarshalNATRule encodes a NAT rule as a csf.redirect line +// ("IPx|portA|IPy|portB|proto"). csf.redirect expresses only destination NAT: a +// Redirect to a local port (IPy = "*") or a DNAT forward to another host +// (IPy = ToAddress). Source NAT, port ranges/lists, and non-tcp/udp protocols +// are not representable. +func (f *CSF) MarshalNATRule(r *NATRule) (string, error) { + if err := r.validate(); err != nil { + return "", err + } + if r.Kind.isSource() { + return "", fmt.Errorf("csf.redirect cannot express source NAT: %w", ErrUnsupportedNAT) + } + if r.Proto != TCP && r.Proto != UDP { + return "", fmt.Errorf("csf.redirect requires a tcp or udp protocol") + } + if r.HasPortSet() { + return "", fmt.Errorf("csf.redirect matches a single port, not a range or list") + } + if r.Source != "" { + return "", fmt.Errorf("csf.redirect cannot match a source address") + } + + ipx := f.redirectAddr(r.Destination) + porta := f.redirectPort(r.Port) + switch r.Kind { + case Redirect: + // A local port redirect: IPy is "*", portB is the target local port. + if r.Port == 0 { + return "", fmt.Errorf("a csf redirect requires a matched port") + } + return strings.Join([]string{ipx, porta, "*", f.redirectPort(r.ToPort), r.Proto.String()}, "|"), nil + case DNAT: + // A forward to another host: IPy is the translation address. csf.redirect + // accepts a DNAT only in two shapes (csf.pl "Invalid csf.redirect format" + // otherwise): a full-IP forward (concrete IPx, both ports "*") or a port + // forward (concrete IPx and both ports concrete). Reject the shapes csf + // would refuse rather than emit a line that aborts the whole redirect load — + // the line still parses back here, so a round-trip check alone misses it. + if r.Destination == "" { + return "", fmt.Errorf("csf.redirect requires a destination address for a forward: %w", ErrUnsupportedNAT) + } + if (r.Port == 0) != (r.ToPort == 0) { + return "", fmt.Errorf("csf.redirect forward requires both a matched and a target port, or neither: %w", ErrUnsupportedNAT) + } + return strings.Join([]string{ipx, porta, r.ToAddress, f.redirectPort(r.ToPort), r.Proto.String()}, "|"), nil + } + return "", fmt.Errorf("csf.redirect cannot express this nat kind: %w", ErrUnsupportedNAT) +} + +// 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 := csfAddrFamily(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 := csfAddrFamily(ipy) + if !ok { + return nil + } + r.Kind = DNAT + r.ToAddress = ipy + r.Family = fam + } + if r.Family == FamilyAny { + r.Family = r.impliedFamily() + } + return r +} + +// GetNATRules reads the NAT rules from csf.redirect. +func (f *CSF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) { + fd, err := os.Open(CSFRedirect) + if err != nil { + // csf.redirect is optional; a missing file simply has no rules. + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer func() { _ = fd.Close() }() + + var rules []*NATRule + 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 + } + // 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). + merged := mergeNATFamilies(rules) + return merged, nil +} + +// 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, err := f.MarshalNATRule(r) + if err != nil { + return err + } + + 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. +func (f *CSF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + return f.editRedirect(r, false) +} + +// RemoveNATRule removes a NAT rule from csf.redirect. +func (f *CSF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + return f.editRedirect(r, true) +} + +// Capabilities reports the firewall features csf supports. +func (f *CSF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + ICMPv6: true, + // A csf.conf port list (TCP_IN="80,443,...") stores each port independently + // and reads back as one rule per port, so a discrete multi-port rule does + // not round-trip as a single rule (a range, kept as one token, does). Report + // PortList as unsupported to reflect that; callers open several ports by + // adding a rule per port, which is how csf stores them anyway. + PortList: false, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: false, + DefaultPolicy: false, + RuleCounters: false, + AddressSets: false, + Comments: 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 is unsupported: csf has no address-set construct. +func (f *CSF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + return nil, unsupportedSet(f.Type()) +} + +// GetAddressSet is unsupported: csf has no address-set construct. +func (f *CSF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + return nil, unsupportedSet(f.Type()) +} + +// AddAddressSet is unsupported: csf has no address-set construct. +func (f *CSF) AddAddressSet(ctx context.Context, set *AddressSet) error { + return unsupportedSet(f.Type()) +} + +// RemoveAddressSet is unsupported: csf has no address-set construct. +func (f *CSF) RemoveAddressSet(ctx context.Context, name string) error { + return unsupportedSet(f.Type()) +} + +// AddAddressSetEntry is unsupported: csf has no address-set construct. +func (f *CSF) AddAddressSetEntry(ctx context.Context, name, entry string) error { + return unsupportedSet(f.Type()) +} + +// RemoveAddressSetEntry is unsupported: csf has no address-set construct. +func (f *CSF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + return unsupportedSet(f.Type()) +} diff --git a/csf_linux_test.go b/csf_linux_test.go new file mode 100644 index 0000000..cf0150f --- /dev/null +++ b/csf_linux_test.go @@ -0,0 +1,888 @@ +package firewall + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// A port-only IPv6 deny carries no address (its ::/0 is synthesized on write), +// so the IPV6-disabled gate must key on the implied family alone. Otherwise the +// line is written, csf.pl drops it as a v6-resolving line under IPV6!=1, and the +// port stays open while GetRules reports it blocked. +func TestCSFIPv6UnavailablePortOnlyDeny(t *testing.T) { + fw := new(CSF) // ipv6Enabled defaults to false — the IPV6-disabled case. + + // The regression: a port-only IPv6 deny (no Source/Destination) must be caught. + require.True(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop}), + "port-only IPv6 deny slipped past the IPV6-disabled gate") + + // A v6-address rule was already caught and must stay caught. + require.True(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Drop})) + + // Not caught: a family-agnostic port-only deny (its 0.0.0.0/0 twin is enforced), + // a plain IPv4 rule, and ICMPv6 (which routes through the raw-iptables hook). + require.False(t, fw.ipv6Unavailable(&Rule{Proto: TCP, Port: 8080, Action: Drop})) + require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop})) + require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept})) + + // With IPv6 enabled the gate never fires. + fw.ipv6Enabled = true + require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop})) +} + +// A ProtocolAny 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 TestCSFProtocolAnyRejectFansOut(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: ProtocolAny, 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 specifically. Before the fix it keyed on Reject, so a Drop deny +// wrote nothing while AddRule reported success — the port stayed 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 ProtocolAny 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 ProtocolAny rule, and be fully +// removed by one RemoveRule. Before the fix the add/remove matcher compared +// ProtocolAny against the concrete-protocol lines exactly, so re-adds duplicated +// the pair and removal was a silent no-op. +func TestCSFProtocolAnyPortDenyRoundTrip(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: ProtocolAny, Port: 80, Action: Drop} + + // Add fans the ProtocolAny 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 ProtocolAny 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 ProtocolAny deny must not duplicate its udp line") + + // The fanned lines read back and collapse to one ProtocolAny rule. + parsed, err := fw.ParseIPList(path, Drop) + require.NoError(t, err) + merged := fw.mergeProtocols(mergeFamilies(parsed)) + require.Len(t, merged, 1, "the tcp+udp deny lines must merge to one ProtocolAny rule") + require.Equal(t, ProtocolAny, merged[0].Proto) + require.True(t, merged[0].EqualBase(deny, true), "the merged rule must equal the ProtocolAny deny that was added") + + // 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 ProtocolAny 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. Regression for the single-"exists" +// gate that skipped the whole fan-out. +func TestCSFPortOnlyDenyHealsMissingFamily(t *testing.T) { + ctx := context.Background() + fw := new(CSF) + 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 ProtocolAny 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: ProtocolAny, 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 ProtocolAny cannot be +// expressed as a single line: csf.pl defaults a protocol-less line to tcp, so +// udp would be silently left open. MarshalAdvRule must reject it rather than +// under-apply it (the port-only form fans out instead, but that path has no +// address to place). +func TestCSFAdvRuleProtocolAnyWithAddressRejected(t *testing.T) { + fw := new(CSF) + _, err := fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: ProtocolAny, Port: 443, Source: "192.0.2.10", Action: Drop}) + require.ErrorIs(t, err, ErrUnsupported, + "an address+port rule with ProtocolAny must be rejected, not written tcp-only") + + // A concrete protocol on the same shape is fine. + _, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Drop}) + require.NoError(t, err) +} + +// A csf tcp/udp advanced rule with an address but no port cannot be expressed: +// csf.pl's linefilter reads the port-flow field by position before the address +// field, so the address shifts into the port slot, gets parsed as a garbage +// --sport/--dport value, and the rule is silently dropped (linefilter requires +// both an address and a port match to install anything). MarshalAdvRule must +// reject this shape rather than emit an unenforceable line. +func TestCSFAdvRuleAddressWithoutPortRejected(t *testing.T) { + fw := new(CSF) + _, err := fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: TCP, Source: "192.0.2.10", Action: Drop}) + require.ErrorIs(t, err, ErrUnsupported, + "a tcp advanced rule with an address and no port must be rejected") + _, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: UDP, Destination: "192.0.2.10", Action: Accept}) + require.ErrorIs(t, err, ErrUnsupported, + "a udp advanced rule with an address and no port must be rejected") + + // An ICMP rule with an address and no port is unaffected by this guard (it + // already requires a concrete ICMP type via a separate check above). + _, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: ICMP, Source: "192.0.2.10", Action: Accept}) + require.Error(t, err, "expected the existing icmp-without-type guard to fire, not this one") +} + +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, err := fw.MarshalAdvRule(c.rule) + require.NoError(t, err, "failed to marshal %+v", *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, false) + 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") + + // MarshalAdvRule rejects rules csf cannot express as advanced rules. + _, err := fw.MarshalAdvRule(&Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept}) + require.Error(t, err, "expected error marshalling an icmpv6 advanced rule") + _, err = fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Action: Accept}) + require.Error(t, err, "expected error marshalling an advanced rule without an address") +} + +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, err := fw.MarshalAdvRule(c.rule) + require.NoError(t, err, "failed to marshal %+v", *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) + } + + // Matching both a source and a destination port is not representable. + _, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}) + require.Error(t, err, "expected error matching both source and destination ports") + + // A source-port rule with no address has nowhere to go. + require.Error(t, fw.checkSourcePort(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept})) +} + +// An ICMP advanced rule that carries an address but no concrete type must be +// rejected: csf's linefilter would consume the address as the icmp-type field +// and silently drop the rule, while the library reported it enforced. +func TestCSFICMPRequiresType(t *testing.T) { + fw := new(CSF) + + // Address but no type: rejected by both the validator and the marshaller. + noType := &Rule{Proto: ICMP, Source: "1.2.3.4", Action: Accept} + require.Error(t, fw.checkICMP(noType)) + _, err := fw.MarshalAdvRule(noType) + require.Error(t, err, "must not emit an ICMP advanced line with the address in the type field") + + // With a concrete type it is a valid advanced rule and round-trips. + typed := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept} + require.NoError(t, fw.checkICMP(typed)) + line, err := fw.MarshalAdvRule(typed) + require.NoError(t, err) + require.Equal(t, "icmp|in|d=8|s=1.2.3.4", line) + parsed := fw.ParseAdvRule(line, Accept) + require.NotNil(t, parsed) + require.True(t, parsed.Equal(typed, true), "round-trip mismatch: %q -> %+v", line, parsed) + + // An address-less ICMP rule is still rejected (advanced rules need an address). + require.Error(t, fw.checkICMP(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept})) +} + +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)) + + // Only a single inbound tcp port, address-less, per-source, reject rule maps. + require.True(t, fw.isConnLimitRule(&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}})) + bad := []*Rule{ + {Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // wrong action (csf rejects, not drops) + {Proto: UDP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // udp + {Proto: TCP, Port: 80, Source: "1.2.3.4", Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // address + {Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // range + {Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}, // global + } + for _, r := range bad { + require.Error(t, fw.checkConnLimit(r), "expected reject for %+v", *r) + } +} + +// 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, err := fw.MarshalNATRule(c.rule) + require.NoError(t, err, "failed to marshal %+v", *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) + } + + // csf.redirect cannot express source NAT, port ranges, source matching, or + // non-tcp/udp protocols. + bad := []*NATRule{ + {Kind: SNAT, ToAddress: "1.2.3.4"}, + {Kind: Masquerade}, + {Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "1.2.3.4"}, + {Kind: DNAT, Proto: ICMP, ToAddress: "1.2.3.4"}, + {Kind: DNAT, Proto: TCP, Source: "1.2.3.4", ToAddress: "5.6.7.8"}, + } + for _, r := range bad { + _, err := fw.MarshalNATRule(r) + require.Error(t, err, "expected error marshalling %+v", *r) + } + + // 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.pl's linefilter drops a csf.allow/csf.deny line (plain or advanced) that +// resolves to an IPv6 address whenever csf.conf's IPV6 is not "1" (the shipped +// default). ipv6Unavailable must flag exactly that shape, and only when +// ipv6Enabled is false; ICMPv6 (always hook-routed) must never be flagged. +func TestCSFIPv6UnavailableGate(t *testing.T) { + disabled := &CSF{ipv6Enabled: false} + enabled := &CSF{ipv6Enabled: true} + + bareV6Host := &Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept} + require.True(t, disabled.ipv6Unavailable(bareV6Host), + "an IPv6 rule must be blocked when csf.conf IPV6 is off") + require.False(t, enabled.ipv6Unavailable(bareV6Host), + "an IPv6 rule must be allowed when csf.conf IPV6 is on") + + bareV4Host := &Rule{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept} + require.False(t, disabled.ipv6Unavailable(bareV4Host), + "an IPv4 rule must never be blocked by the IPv6 gate") + + // ICMPv6 always routes through the raw-iptables hook, which runs outside + // csf's own IPV6-gated logic, so it must not be blocked either way. + icmpv6 := &Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept} + require.False(t, disabled.ipv6Unavailable(icmpv6), + "an icmpv6 rule must not be blocked by the IPv6 gate") +} + +// 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") +} + +// A ported csf advanced rule that matches both a source and a destination address +// cannot be expressed (a csf advanced rule holds a single address field) and must +// be rejected rather than silently dropping the destination. The portless bare +// form is not tested here: AddRule diverts it to the raw-iptables hook +// (hostNeedsHook), so the writer never sees it. +func TestCSFDualAddressRejected(t *testing.T) { + fw := new(CSF) + _, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}) + require.Error(t, err, "a dual-address csf advanced rule must be rejected") +} + +// csf's port lists are TCP_IN/UDP_IN; a concrete non-tcp/udp protocol (sctp) would +// wrongly be written into both, so it must error. ProtocolAny is allowed (it maps +// to both lists as the faithful "any" expansion). +func TestCSFPortProtoGuard(t *testing.T) { + fw := new(CSF) + require.Error(t, fw.checkPortProto(&Rule{Proto: SCTP, Port: 80, Action: Accept}), "sctp port must be rejected") + require.NoError(t, fw.checkPortProto(&Rule{Port: 80, Action: Accept}), "ProtocolAny port is allowed for csf") + require.NoError(t, fw.checkPortProto(&Rule{Proto: TCP, Port: 80, Action: Accept})) + require.NoError(t, fw.checkPortProto(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept})) +} + +// csf.conf's CONNLIMIT is a single dual-stack config key (it caps both v4 and v6 +// connections; there is no v6 variant to merge), 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. This mirrors the APF behavior above. +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") +} + +// csf.redirect only expresses a DNAT with a concrete matched destination and +// either both a matched and target port or neither. MarshalNATRule must reject +// the shapes csf would refuse rather than emit a line that aborts the redirect +// load — a round-trip test alone would not catch it. +func TestCSFDNATMarshalRejectsUnexpressible(t *testing.T) { + f := new(CSF) + bad := []struct { + name string + rule *NATRule + }{ + {"empty destination (ipx=*)", &NATRule{Kind: DNAT, Proto: TCP, Port: 666, ToAddress: "10.0.0.1", ToPort: 25}}, + {"matched port but no target port", &NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", Port: 80, ToAddress: "5.6.7.8"}}, + {"target port but no matched port", &NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", ToAddress: "5.6.7.8", ToPort: 8080}}, + } + for _, c := range bad { + _, err := f.MarshalNATRule(c.rule) + require.Errorf(t, err, "csf.redirect should reject %s", c.name) + // The rejection must carry ErrUnsupportedNAT so callers (and the integration + // harness's roundTripNATVariants) treat the shape as unexpressible and skip + // it rather than seeing a hard error. + require.ErrorIsf(t, err, ErrUnsupportedNAT, "%s should be reported as unsupported NAT", c.name) + } + + // The two shapes csf accepts still marshal: a full-IP forward (no ports) and a + // concrete port forward. + full, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", ToAddress: "5.6.7.8"}) + require.NoError(t, err) + require.Equal(t, "1.2.3.4|*|5.6.7.8|*|tcp", full) + fwd, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", Port: 80, ToAddress: "5.6.7.8", ToPort: 8080}) + require.NoError(t, err) + require.Equal(t, "1.2.3.4|80|5.6.7.8|8080|tcp", fwd) +} + +// TestCSFBareProtocolRoutesToHook guards that a rule with no port and no address +// (a bare protocol match) is injected through the pre-hook rather than rejected or +// silently written nowhere. csf keys every native rule on a port or an address, so +// such a rule maps to no csf construct; iptables expresses it directly, so addRule +// diverts it to the hook (bareProtoNeedsHook). ICMP keeps its own handling. +func TestCSFBareProtocolRoutesToHook(t *testing.T) { + for _, r := range []*Rule{ + {Proto: TCP, Action: Accept}, + {Proto: ProtocolAny, Action: Accept}, + {Proto: UDP, Action: Drop}, + } { + require.True(t, bareProtoNeedsHook(r), + "a portless, addressless rule must route to the hook, not be rejected: %+v", r) + } + require.False(t, bareProtoNeedsHook(&Rule{Proto: ICMP, Action: Accept}), + "an ICMP rule keeps its own handling and is excluded from the bare-protocol hook route") +} + +// 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 mergeFamilies +// collapses the v4/v6 pair a family-neutral rule writes. +func TestCSFPortOnlyRejectRoundTrip(t *testing.T) { + fw := new(CSF) + 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) + + // GetRules applies mergeFamilies to the parsed list; mirror it here. + got, err := fw.ParseIPList(deny, Reject) + require.NoError(t, err) + got = mergeFamilies(got) + require.Len(t, got, 1, "port-only reject (%s) must round-trip to one rule", rule.Family) + require.True(t, rule.Equal(got[0], false), "read-back rule must equal the written one: %+v", got[0]) + + // 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 (hostNeedsHook) 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 — advMatch matches 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 — the EqualForDedup/EqualForRemoval +// gate in advMatch must not disturb the FamilyAny case. +func TestCSFFamilyAnyAdvDenyRoundTrip(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: 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") +} + +// TestCSFMergeProtocolsRespectsFamily guards mergeProtocols against collapsing a +// tcp/udp pair that differ in IP family. 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 must NOT merge into one ProtocolAny rule — doing so drops the IPv6 +// coverage from the read-back and makes Sync churn forever. +func TestCSFMergeProtocolsRespectsFamily(t *testing.T) { + fw := new(CSF) + // tcp/IPv4 and udp/IPv6, both port 53 inbound — same in every field but proto + // and family. + tcpV4 := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept} + udpV6 := &Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept} + + out := fw.mergeProtocols([]*Rule{tcpV4, udpV6}) + + if len(out) != 2 { + t.Fatalf("a cross-family tcp/udp pair must not merge: got %d rules %+v, want 2", len(out), out) + } + // Both families must still be represented. + var haveV4, haveV6 bool + for _, r := range out { + switch r.impliedFamily() { + case IPv4: + haveV4 = true + case IPv6: + haveV6 = true + } + } + if !haveV4 || !haveV6 { + t.Fatalf("both families must survive: haveV4=%v haveV6=%v (%+v)", haveV4, haveV6, out) + } +} + +// TestCSFMergeProtocolsSameFamily confirms the intended merge still happens for a +// same-family tcp/udp pair (the fanned-out form of one ProtocolAny port rule). +func TestCSFMergeProtocolsSameFamily(t *testing.T) { + fw := new(CSF) + tcp := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept} + udp := &Rule{Family: IPv4, Proto: UDP, Port: 53, Action: Accept} + + out := fw.mergeProtocols([]*Rule{tcp, udp}) + if len(out) != 1 { + t.Fatalf("a same-family tcp/udp pair should merge to one rule: got %d %+v", len(out), out) + } + if out[0].Proto != ProtocolAny { + t.Fatalf("merged rule should be ProtocolAny, got %v", out[0].Proto) + } +} + +// 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") +} diff --git a/firewall.go b/firewall.go new file mode 100644 index 0000000..507022b --- /dev/null +++ b/firewall.go @@ -0,0 +1,2268 @@ +package firewall + +import ( + "context" + "errors" + "fmt" + "net" + "sort" + "strconv" + "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 +// (rejectLogAndLimit, unsupportedNAT, ...) 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) +) + +// Action is the firewall action taken on a rule's matching packets. +type Action uint8 + +const ( + ActionInvalid Action = iota + Accept + Reject + 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 Family = iota + IPv4 + 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. 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 Protocol = iota + UDP + TCP + // ICMP and ICMPv6 are the control-message protocols. ICMP is inherently an + // IPv4 protocol and ICMPv6 an IPv6 one; a rule carrying one of these implies + // the matching family (see Rule.impliedFamily). + 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 +) + +// 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 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 or +// SCTP). A port match is only meaningful and only valid for these protocols. +func (t Protocol) HasPorts() bool { + return t == TCP || t == UDP || t == SCTP +} + +// 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 and other tools print to +// their numeric type. 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, + "nd-router-advert": 134, + "nd-neighbor-solicit": 135, + "nd-neighbor-advert": 136, + "nd-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. +func GetProtocol(proto string) Protocol { + switch { + case strings.EqualFold("udp", proto): + return UDP + case strings.EqualFold("tcp", proto): + return TCP + 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 uint16 + 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 ConnState = 1 << iota + StateEstablished + StateRelated + 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 RateUnit = iota + PerMinute + PerHour + 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 uint + Unit RateUnit + Burst uint // 0 leaves the burst at the backend default. +} + +// String renders the limit as "/" (e.g. "10/minute"). +func (rl RateLimit) String() string { + return fmt.Sprintf("%d/%s", rl.Rate, rl.Unit) +} + +// parseRateToken parses a "/" 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 uint + 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 +} + +// 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 { + addr = strings.TrimPrefix(strings.TrimSpace(addr), "!") + if addr == "" { + return FamilyAny + } + ip, _, err := net.ParseCIDR(addr) + if err != nil { + ip = net.ParseIP(addr) + } + if ip == nil { + return FamilyAny + } + if ip.To4() == nil { + return IPv6 + } + return IPv4 +} + +// 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 +} + +// 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 +} + +// 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() +} + +// Rule is a firewall rule. +type Rule struct { + // Direction places the rule in the input, output or forward chain. The zero + // value (DirInput) is an input rule. A forward rule filters traffic routed + // through the host and may match both an inbound and an outbound interface; + // backends that cannot express a forward chain reject such a rule with + // ErrUnsupportedForward (see Capabilities.Forward). + Direction Direction + Priority int + Family Family + Source string + Destination string + // Port is the single destination port to match. For a port list or range, + // use Ports instead; when Ports is non-empty it takes precedence over Port. + Port uint16 + // Ports is a list of destination port ranges to match. A single port is a + // range whose Start and End are equal. Backends that cannot express a + // multi-port match reject such a rule rather than silently narrowing it. + Ports []PortRange + // SourcePort is the single source port to match. For a port list or range, + // use SourcePorts instead; when SourcePorts is non-empty it takes precedence + // over SourcePort. + SourcePort uint16 + // SourcePorts is a list of source port ranges to match. Backends that cannot + // express source-port matching reject a rule carrying one rather than silently + // omit the match. + SourcePorts []PortRange + Proto Protocol + // ICMPType, when set, restricts an ICMP/ICMPv6 rule to a single ICMP message + // type (e.g. 8 for echo-request). A nil pointer matches every ICMP type. It + // is only meaningful when Proto is ICMP or ICMPv6. + ICMPType *uint8 + // State restricts the rule to the given connection-tracking states (e.g. + // StateEstablished|StateRelated). The zero value applies no state match. + State ConnState + // InInterface matches the inbound interface; OutInterface matches the outbound + // interface. An input rule matches only InInterface, an output rule only + // OutInterface, and a forward rule may match either or both (ingress and + // egress). Empty means any interface. + InInterface string + OutInterface string + Action Action + // Log, when set, logs each matched packet before the Action is applied. + // LogPrefix is an optional label attached to the log line. Backends that + // cannot express per-rule logging reject a rule carrying it. + Log bool + LogPrefix string + // RateLimit caps the packet rate the rule matches; ConnLimit caps the + // concurrent connections it matches. A nil pointer applies no limit. Both + // are rejected by backends that cannot express them. + RateLimit *RateLimit + ConnLimit *ConnLimit + // Packets and Bytes are per-rule counters, populated by GetRules on backends + // that can read them (nftables, iptables and pf). They are zero on backends + // that cannot read counters, and are ignored when adding a rule. They are + // not part of rule identity, so two rules that differ only in counters are + // considered equal. + Packets uint64 + Bytes uint64 + // Comment is an optional human-readable label carried alongside the rule on + // backends that can store one (an iptables/nftables comment, a pf label, a + // CSF/APF trailing comment, a WFP description). Like Packets/Bytes it is + // informational: it is not part of rule identity, so two rules that differ + // only in their comment are considered equal, and backends that cannot store + // a comment ignore it when adding a rule and report it empty on read. A + // caller can check Capabilities().Comments to learn whether it round-trips. + Comment string + // HasPrefix reports whether this rule carries the library's configured prefix. + // For backends that tag rules with the configured prefix — in a comment, or (for + // name-based backends) in the rule name — it reports whether that prefix is + // present. For container backends (nft, pf, firewalld) that isolate rules in a + // private table/anchor/zone, it reports whether the rule lives in that container + // (recorded in the unexported table field). It is derived on read and purely + // informational — the library never branches on it: GetRules returns every rule + // the backend can see, and HasPrefix lets callers tell which ones the library + // created. An empty rule prefix gives a tag-based backend no namespace of its + // own, so its rules report HasPrefix=false. + // Like Comment and Packets/Bytes it is not part of rule identity, so two rules + // that differ only in HasPrefix are considered equal, and it is ignored when + // adding a rule. + HasPrefix bool + // Number is the rule's 1-based position within its chain in the backend's native + // ordering, populated by GetRules on backends that support explicit ordering + // (Capabilities().RuleOrdering). It mirrors the position argument of InsertRule + // and MoveRule, so a caller can read a rule's Number and pass it back to reorder. + // It is zero on backends without ordering and on rules outside the backend's + // ordered set (foreign rules in a container backend's read). For a rule merged + // across IPv4 and IPv6 it reflects the IPv4 chain. Like HasPrefix and + // Packets/Bytes it is derived on read, ignored when adding a rule, and not part + // of rule identity. + Number int + // table records the backend container a container backend (an nft table, a pf + // anchor, a firewalld zone) read this rule from; it is empty for tag-based + // backends. It is unexported and informational — it backs HasPrefix for the + // container backends and, like HasPrefix, is not part of rule identity. + table string +} + +// directionFromOutput maps an input/output boolean to a Direction. Backends whose +// native config distinguishes only inbound from outbound use it when decoding a +// rule (a routed/forward rule is expressed through their raw-iptables hook, not +// their native config, so it is never decoded here). It only ever yields a +// concrete DirInput or DirOutput; DirAny is synthesized later by the read-side +// direction merge (see mergeDirections), never by a single-row decode. +func directionFromOutput(output bool) Direction { + if output { + return DirOutput + } + return DirInput +} + +// IsInput reports whether the rule is an input (inbound) rule. A DirAny rule is +// not an input rule; use AppliesInput to ask whether a rule materializes into the +// input chain. +func (r *Rule) IsInput() bool { return r.Direction == DirInput } + +// IsOutput reports whether the rule is an output (outbound) rule. A DirAny rule is +// not an output rule; use AppliesOutput for chain materialization. +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 } + +// IsAny reports whether the rule applies to both directions (DirAny). +func (r *Rule) IsAny() bool { return r.Direction == DirAny } + +// AppliesInput reports whether the rule materializes into the input chain — a +// concrete input rule or a both-directions DirAny rule. +func (r *Rule) AppliesInput() bool { return r.Direction == DirInput || r.Direction == DirAny } + +// AppliesOutput reports whether the rule materializes into the output chain — a +// concrete output rule or a both-directions DirAny rule. +func (r *Rule) AppliesOutput() bool { return r.Direction == DirOutput || r.Direction == DirAny } + +// 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 +} + +// 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 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. UFW is the exception and can express a +// bare port across any protocol. +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 +// both the DirAny write-side fan-out (expandDirections) and the read-side merge +// (mergeDirections). 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. +func (r *Rule) matchFields(rule *Rule, directionHonored bool) bool { + if r.Direction != rule.Direction && directionHonored { + 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). +func (r *Rule) Equal(rule *Rule, outputHonored bool) bool { + if r.impliedFamily() != rule.impliedFamily() { + return false + } + return r.matchFields(rule, outputHonored) +} + +// EqualBase reports whether two rules are the same, ignoring family. +func (r *Rule) EqualBase(rule *Rule, outputHonored bool) bool { + return r.matchFields(rule, outputHonored) +} + +// coversDirection reports whether an existing rule in direction have already +// covers a caller rule in 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 outputHonored is false the backend has no +// output concept, so direction never distinguishes two rules and coverage is +// unconditional — behavior identical to the pre-DirAny code. +func coversDirection(have, want Direction, outputHonored bool) bool { + if !outputHonored { + return true + } + 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, outputHonored bool) bool { + if !outputHonored { + return true + } + 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, and the receiver's family and direction +// both cover 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, outputHonored bool) bool { + fe, fr := r.impliedFamily(), o.impliedFamily() + if !(fe == FamilyAny || fe == fr) { + return false + } + if !coversDirection(r.Direction, o.Direction, outputHonored) { + return false + } + return r.canonicalMatch().EqualBase(o.canonicalMatch(), false) +} + +// 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 and direction +// may touch the row's. It is the family- and direction-aware remove/move guard for +// the backends whose GetRules merges a v4/v6 pair into one FamilyAny rule or an +// in/out pair into one DirAny rule — a FamilyAny/DirAny target matches every row on +// that axis, a FamilyAny/DirAny row matches any target, and otherwise the values +// must match so acting on one twin never disturbs the other. Family and direction +// are checked first so a non-matching row skips the field compare, which runs in +// the inbound frame (canonicalMatch) with direction excluded. +func (r *Rule) EqualForRemoval(o *Rule, outputHonored bool) bool { + ft, fr := o.impliedFamily(), r.impliedFamily() + if !(ft == FamilyAny || fr == FamilyAny || ft == fr) { + return false + } + if !coversDirectionRemoval(r.Direction, o.Direction, outputHonored) { + return false + } + return r.canonicalMatch().EqualBase(o.canonicalMatch(), 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 +} + +// oppositeDirection returns the other concrete traffic direction: DirOutput for +// DirInput and vice versa. DirForward and DirAny have no opposite and return +// DirAny (the sentinel the direction merge treats as "no pair"). It supports the +// direction merge and the dual-row split on removal. +func oppositeDirection(d Direction) Direction { + switch d { + case DirInput: + return DirOutput + case DirOutput: + return DirInput + default: + return DirAny + } +} + +// 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} +} + +// 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 + } +} + +// 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. +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 +} + +// unsupportedNAT is the error a backend returns from its NAT methods when its +// model cannot express network address translation. backend names the backend. +func unsupportedNAT(backend string) error { + return fmt.Errorf("%s does not support NAT in this model: %w", backend, ErrUnsupportedNAT) +} + +// 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) +} + +// 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 +} + +// familyMergePairs computes the cross-family pairing shared by every merge +// helper below (filter and NAT, both the collapse and the anchor-index forms), so +// the pairing rule lives in exactly one place. For n rows it scans in order and, +// for each concrete-family row not already absorbed, finds the first later +// opposite-family row whose base is equal and marks it absorbed — each anchor +// absorbs at most one twin. family reports a row's IP family; equalBase reports +// whether rows i and j are equal ignoring family. It returns, per index, whether +// that row was absorbed into an earlier anchor, and per anchor the index of the +// twin it absorbed (-1 if none). It mutates nothing. +func familyMergePairs(n int, family func(int) Family, equalBase func(i, j int) bool) (absorbed []bool, twin []int) { + absorbed = make([]bool, n) + twin = make([]int, n) + for i := range twin { + twin[i] = -1 + } + for i := 0; i < n; i++ { + if absorbed[i] || family(i) == FamilyAny { + continue + } + for j := i + 1; j < n; j++ { + if absorbed[j] || family(j) == FamilyAny || family(i) == family(j) { + continue + } + if equalBase(i, j) { + absorbed[j] = true + twin[i] = j + break + } + } + } + return absorbed, twin +} + +// mergeFamilies collapses pairs of otherwise-identical rules that differ only in +// IP family (one IPv4, one IPv6) into a single FamilyAny rule. Rules that are +// already FamilyAny, or two rules of the same family, are left untouched so that +// a duplicate within a single family is never mistaken for cross-family +// coverage. The surviving anchor of each merged pair is flipped to FamilyAny. +func mergeFamilies(rules []*Rule) []*Rule { + absorbed, twin := familyMergePairs(len(rules), + func(i int) Family { return rules[i].Family }, + func(i, j int) bool { return rules[i].EqualBase(rules[j], true) }) + out := make([]*Rule, 0, len(rules)) + for i, r := range rules { + if absorbed[i] { + continue + } + if twin[i] >= 0 { + r.Family = FamilyAny + } + out = append(out, r) + } + return out +} + +// mergeFamiliesCopy is mergeFamilies over value-copies of the input rules, so the +// caller's rules (and their Family fields) are left untouched. Sync uses it to +// canonicalize a desired set the same way GetRules canonicalizes existing rules, +// without mutating the caller's slice. +func mergeFamiliesCopy(rules []*Rule) []*Rule { + cp := make([]*Rule, len(rules)) + for i, r := range rules { + rc := *r + cp[i] = &rc + } + return mergeFamilies(cp) +} + +// directionMergePairs computes the input/output pairing for the direction merge, +// mirroring familyMergePairs on the direction axis. For n rows it scans in order +// and, for each concrete-direction (input or output) row not already absorbed, +// finds the first later row of the opposite direction that is equal once both are +// put in the inbound frame and marks it absorbed — each anchor absorbs at most one +// twin. Forward and already-merged DirAny rows never pair. dir reports a row's +// direction; equalSwapped reports whether rows i and j are equal in the inbound +// frame. It returns, per index, whether that row was absorbed into an earlier +// anchor, and per anchor the index of the twin it absorbed (-1 if none). +func directionMergePairs(n int, dir func(int) Direction, equalSwapped func(i, j int) bool) (absorbed []bool, twin []int) { + absorbed = make([]bool, n) + twin = make([]int, n) + for i := range twin { + twin[i] = -1 + } + for i := 0; i < n; i++ { + if absorbed[i] || (dir(i) != DirInput && dir(i) != DirOutput) { + continue + } + for j := i + 1; j < n; j++ { + if absorbed[j] || dir(j) != oppositeDirection(dir(i)) { + continue + } + if equalSwapped(i, j) { + absorbed[j] = true + twin[i] = j + break + } + } + } + return absorbed, twin +} + +// mergeDirections collapses pairs of otherwise-identical rules that differ only in +// traffic direction — an input rule and its role-swapped output twin — into a +// single DirAny rule. It is the direction analog of mergeFamilies and is applied +// after it in GetRules, so a rule present as {v4-in, v6-in, v4-out, v6-out} first +// collapses per family to one input and one output row, then merges here to one +// FamilyAny+DirAny rule. Pairing compares in the inbound frame (canonicalMatch) and +// honors family through Equal, so an input rule never merges with an opposite- +// family output rule. The surviving anchor is normalized to the inbound frame and +// flipped to DirAny. Forward rules never merge. +func mergeDirections(rules []*Rule) []*Rule { + absorbed, twin := directionMergePairs(len(rules), + func(i int) Direction { return rules[i].Direction }, + func(i, j int) bool { + return rules[i].canonicalMatch().Equal(rules[j].canonicalMatch(), false) + }) + out := make([]*Rule, 0, len(rules)) + for i, r := range rules { + if absorbed[i] { + continue + } + if twin[i] >= 0 { + // Present the merged rule in the inbound frame; an output anchor is + // role-swapped so DirAny{Source:X} reads back consistently. + if r.Direction == DirOutput { + r = r.directionSwapped() + } + r.Direction = DirAny + } + out = append(out, r) + } + return out +} + +// mergeDirectionsCopy is mergeDirections over value-copies of the input rules, so +// the caller's rules (and their Direction fields) are left untouched. Sync uses it +// alongside mergeFamiliesCopy to canonicalize a desired set the same way GetRules +// canonicalizes existing rules, without mutating the caller's slice. +func mergeDirectionsCopy(rules []*Rule) []*Rule { + cp := make([]*Rule, len(rules)) + for i, r := range rules { + rc := *r + cp[i] = &rc + } + return mergeDirections(cp) +} + +// mergedFamilyAnchors returns the indices of the rules that survive mergeFamilies: +// the anchor (kept) row of each logical rule, in order. mergeFamilies collapses an +// otherwise-identical IPv4/IPv6 pair into the earlier of the two and drops the +// later, so a rule's merged 1-based Number is its position in this anchor sequence. +// A backend whose read merges families but whose chain edits address physical rows +// (nftables) uses this to map a caller-supplied Number/position back to a physical +// chain index. It shares familyMergePairs with mergeFamilies, so the pairing can +// never drift; unlike mergeFamilies it leaves the rules' Family fields untouched, +// since callers pass a physical list they only want to index into. When no pair +// merges, every index is its own anchor. +func mergedFamilyAnchors(rules []*Rule) []int { + absorbed, _ := familyMergePairs(len(rules), + func(i int) Family { return rules[i].Family }, + func(i, j int) bool { return rules[i].EqualBase(rules[j], true) }) + return survivingAnchors(absorbed) +} + +// survivingAnchors returns the indices not marked absorbed, in order — the anchor +// row of each merged pair. Shared by the filter and NAT anchor helpers. +func survivingAnchors(absorbed []bool) []int { + anchors := make([]int, 0, len(absorbed)) + for i, gone := range absorbed { + if !gone { + anchors = append(anchors, i) + } + } + return anchors +} + +// mergedInsertIndex maps a 1-based merged position (a rule's Number, as GetRules +// reports it) to the 0-based physical index to insert before, given the merged +// anchors of the physical list and its row count. A position past the last logical +// rule appends (returns physicalLen). When no IPv4/IPv6 pair has merged, every row +// is its own anchor and this reduces to position-1 — the plain physical index — so +// the common single-representation case is unaffected. It exists because the read +// path merges v4/v6 pairs (so Number counts logical rules) while physical edits act +// on physical rows; without this mapping an insert lands too early by the number of +// merged pairs preceding it. Backends that edit a physical, merge-collapsed list +// (nftables chains, pf's anchor) share it. +func mergedInsertIndex(anchors []int, physicalLen, position int) int { + if position < 1 { + position = 1 + } + if position-1 >= len(anchors) { + return physicalLen + } + return anchors[position-1] +} + +// 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 must run before +// mergeDirections so the surviving pure-output rows keep the physical output +// position of their still-present twin. 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 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 the canonical +// name emitted by NATKind.String. An unknown value is an error. +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 + case "invalid": + return NATInvalid, 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 AddNATRule/RemoveNATRule/GetNATRules methods. +type NATRule struct { + Kind NATKind + Family Family + Proto Protocol + // Interface matches the inbound interface for DNAT/Redirect and the + // outbound interface for SNAT/Masquerade. Empty means any interface. + Interface string + Source string + Destination string + // Port is the single matched destination port; Ports is a list/range that + // overrides Port when non-empty. A port match requires a concrete tcp/udp + // protocol, as with filter rules. + Port uint16 + 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 this NAT rule is one the library recognizes as its + // own — the configured prefix in its comment for tag-based backends, or + // membership in the library's private table/anchor/zone for container backends + // (recorded in the unexported table field). Like Rule.HasPrefix it is derived on + // read, informational, not part of rule identity, and ignored when adding a rule. + HasPrefix bool + // Number is the NAT rule's 1-based position within its nat chain in the backend's + // native ordering, populated by GetNATRules on backends that support explicit + // ordering (Capabilities().RuleOrdering). It mirrors the position argument of + // InsertNATRule. It is zero on backends without ordering and on rules outside the + // backend's ordered set. Like HasPrefix it is derived on read, ignored when adding + // a rule, and not part of rule identity. + Number int + // table records the container a container backend read this NAT rule from (empty + // for tag-based backends); unexported and informational, backing 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 +} + +// 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") + } + if r.ToPort != 0 { + return fmt.Errorf("snat does not translate the port") + } + case Masquerade: + if r.ToAddress != "" || r.ToPort != 0 { + return fmt.Errorf("masquerade takes no translation target") + } + default: + return fmt.Errorf("invalid nat kind") + } + 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 { + fe, fr := r.impliedFamily(), o.impliedFamily() + return (fe == FamilyAny || fe == fr) && r.EqualBase(o) +} + +// 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) +} + +// mergeNATFamilies collapses IPv4/IPv6 pairs of otherwise-identical NAT rules +// into a single FamilyAny rule when reading rules back, mirroring mergeFamilies +// for filter rules. +func mergeNATFamilies(rules []*NATRule) []*NATRule { + absorbed, twin := familyMergePairs(len(rules), + func(i int) Family { return rules[i].Family }, + func(i, j int) bool { return rules[i].EqualBase(rules[j]) }) + out := make([]*NATRule, 0, len(rules)) + for i, r := range rules { + if absorbed[i] { + continue + } + if twin[i] >= 0 { + r.Family = FamilyAny + } + out = append(out, r) + } + return out +} + +// mergedNATFamilyAnchors returns the indices of the NAT rules that survive +// mergeNATFamilies, mirroring mergedFamilyAnchors for NAT rules so a merged NAT +// rule's Number maps back to a physical nat-chain index for InsertNATRule. +func mergedNATFamilyAnchors(rules []*NATRule) []int { + absorbed, _ := familyMergePairs(len(rules), + func(i int) Family { return rules[i].Family }, + func(i, j int) bool { return rules[i].EqualBase(rules[j]) }) + return survivingAnchors(absorbed) +} + +// numberNATByChain assigns each NAT rule a 1-based Number within its nat chain — +// prerouting for destination NAT, postrouting for source NAT — matching the +// InsertNATRule 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). +func numberNATSequential(rules []*NATRule) { + for i, r := range rules { + r.Number = i + 1 + } +} + +// 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 +} + +// 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 read-side merge collapses an input rule and its + // role-swapped output twin into one DirAny rule, and a write-side fan-out + // expands a DirAny rule back into a concrete input row plus a swapped output + // row. 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 Action + Output Action + Forward Action +} + +// 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 +} + +// 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 + // ICMPv6 is true when ICMPv6 protocol/type matching is honored. (Plain ICMP + // and ICMP-type matching are supported by every backend, so they are not + // advertised as capabilities.) + ICMPv6 bool + // PortList is true when multi-port (comma list) matching is honored. (Port + // ranges are supported by every backend, so they are not advertised.) + PortList 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/RateLimit/ConnLimit describe the per-rule modifiers. + Logging bool + RateLimit bool + 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. + 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 +} + +// 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 + + // RemoveNATRule removes a NAT rule from the zone. + RemoveNATRule(ctx context.Context, zoneName string, rule *NATRule) 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 + + // 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. + 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 + + // 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 +} + +// RuleBatcher is an optional interface a Manager may implement to apply many +// rules in a single atomic operation instead of one AddRule per rule. Backends +// whose commit is a whole-file or whole-ruleset load (iptables-restore, nft -f, +// pfctl -f) implement it so a batch is one syscall storm rather than N, and so a +// partial failure leaves the managed set unchanged. The package-level AddRules, +// ReplaceRules and Sync helpers use it when the backend provides it and fall +// back to per-rule operations otherwise, so callers need not type-assert. +type RuleBatcher interface { + // AddRulesBatch adds every rule in one atomic operation. Rules that already + // exist are skipped, mirroring AddRule. Either all applicable rules are added + // or, on error, none are. + AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error + // ReplaceRulesBatch atomically replaces the backend's filter rules with + // exactly rules: any rule the backend acts on that is not present in rules is + // removed (it does not filter on HasPrefix) and any missing rule is added, in + // one operation. + ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error +} + +// AddRules adds every rule in rules to the zone. When mgr implements RuleBatcher +// the rules are applied in a single atomic operation; otherwise AddRules falls +// back to calling AddRule for each rule in turn (stopping at the first error). +func AddRules(ctx context.Context, mgr Manager, zoneName string, rules []*Rule) error { + if b, ok := mgr.(RuleBatcher); ok { + return b.AddRulesBatch(ctx, zoneName, rules) + } + for _, r := range rules { + if err := mgr.AddRule(ctx, zoneName, r); err != nil { + return err + } + } + return nil +} + +// ReplaceRules makes the zone's filter rules equal to rules. It reconciles the +// actual firewall state (it does not filter on HasPrefix), so a rule not in rules +// is removed whether or not it carries the configured prefix. When mgr implements +// RuleBatcher the replacement is atomic; otherwise ReplaceRules falls back to Sync, +// which applies a minimal add/remove diff (a rule unchanged between the existing +// and desired sets is never removed and re-added). On a non-batching backend, Sync +// still removes unwanted existing rules before adding new ones, so replacing with a +// fully disjoint rule set leaves a real window with none of the desired rules +// present. +func ReplaceRules(ctx context.Context, mgr Manager, zoneName string, rules []*Rule) error { + if b, ok := mgr.(RuleBatcher); ok { + return b.ReplaceRulesBatch(ctx, zoneName, rules) + } + _, _, err := Sync(ctx, mgr, zoneName, rules) + return err +} + +// Sync reconciles the zone's filter rules toward desired: it removes any rule +// the backend reports that is not in desired 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. Rule +// identity is compared with Rule.Equal honoring +// the backend's Output capability, so the Comment, HasPrefix and Packets/Bytes +// fields do not 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 + } + outputHonored := mgr.Capabilities().Output + + // Canonicalize desired the same way GetRules canonicalizes existing: collapse an + // otherwise-identical IPv4/IPv6 pair into one FamilyAny rule, then collapse an + // input rule and its role-swapped output twin into one DirAny rule. GetRules + // merges such pairs on read, so a caller that lists the families or directions + // separately would never match the single merged rule and Sync would + // remove-and-re-add it on every run (a transient gap). Copy first — the merges + // mutate their input's fields and slice. Family before direction, matching the + // order in every backend's GetRules. + desired = mergeFamiliesCopy(desired) + desired = mergeDirectionsCopy(desired) + + // Remove any existing rule that is not wanted. 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. + for _, e := range existing { + keep := false + for _, d := range desired { + if e.Equal(d, outputHonored) { + keep = true + break + } + } + if !keep { + if err := mgr.RemoveRule(ctx, zoneName, e); err != nil { + return added, removed, err + } + removed++ + } + } + + // Add any wanted rule that is not already present. + var toAdd []*Rule + for _, d := range desired { + present := false + for _, e := range existing { + // A rule already in the firewall — whoever created it — counts as present, + // so Sync does not add a duplicate of an identical existing rule. + if e.Equal(d, outputHonored) { + present = true + break + } + } + // Skip a duplicate within desired itself: if an equal rule is already + // queued, adding it again would double-apply it on a RuleBatcher backend + // (which sees both as new) and over-count added on the others. + if !present { + for _, a := range toAdd { + if a.Equal(d, outputHonored) { + present = true + break + } + } + } + if !present { + toAdd = append(toAdd, d) + } + } + if err := AddRules(ctx, mgr, zoneName, toAdd); err != nil { + return added, removed, err + } + added = len(toAdd) + return added, removed, nil +} + +// 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. +func unsupportedSet(backend string) error { + return fmt.Errorf("%s does not support address sets in this model: %w", backend, ErrUnsupportedSet) +} diff --git a/firewall_test.go b/firewall_test.go new file mode 100644 index 0000000..b80ce53 --- /dev/null +++ b/firewall_test.go @@ -0,0 +1,1379 @@ +package firewall + +import ( + "bytes" + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// impliedFamily must fall back to a concrete source/destination address when the +// family is unset and no ICMP protocol pins it, mirroring NATRule.impliedFamily. +// An IPv4-only rule left FamilyAny must not be treated as touching both families +// (which sends an IPv4 address into the ip6tables save file, rejected on load). +func TestRuleImpliedFamilyFromAddress(t *testing.T) { + require.Equal(t, IPv4, (&Rule{Source: "192.168.0.1"}).impliedFamily()) + require.Equal(t, IPv6, (&Rule{Destination: "2001:db8::1"}).impliedFamily()) + require.Equal(t, IPv4, (&Rule{Source: "10.0.0.0/8"}).impliedFamily()) + // An explicit family always wins over an address hint. + require.Equal(t, IPv6, (&Rule{Family: IPv6, Source: "192.168.0.1"}).impliedFamily()) + // An ICMP protocol still pins the family before any address is consulted. + require.Equal(t, IPv4, (&Rule{Proto: ICMP}).impliedFamily()) + // No family, no proto, no address stays ambiguous. + require.Equal(t, FamilyAny, (&Rule{}).impliedFamily()) +} + +// 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; the CLI used to omit + // them, so 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) + } + } +} + +// syncRecorder is a Manager with an empty existing rule set that records the +// rules Sync adds, so the diff logic can be exercised without a live firewall. +type syncRecorder struct { + restoreRecorder + added []*Rule +} + +func (m *syncRecorder) AddRule(_ context.Context, _ string, r *Rule) error { + m.added = append(m.added, r) + return nil +} + +// Sync must not queue two identical desired rules for a double add: the second +// is equal to the first and, against a RuleBatcher, both would be seen as new. +func TestSyncSkipsDuplicateDesired(t *testing.T) { + m := &syncRecorder{} + desired := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, + {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, + } + added, removed, err := Sync(context.Background(), m, "", desired) + require.NoError(t, err) + require.Equal(t, 0, removed) + require.Equal(t, 1, added, "two identical desired rules must be added once") + require.Len(t, m.added, 1) +} + +// syncDirRecorder is a Manager with a fixed existing rule set that records the +// rules Sync adds and removes, so the DirAny reconcile can be exercised offline. +type syncDirRecorder struct { + restoreRecorder + existing []*Rule + added []*Rule + removed []*Rule +} + +func (m *syncDirRecorder) GetRules(context.Context, string) ([]*Rule, error) { + return m.existing, nil +} +func (m *syncDirRecorder) AddRule(_ context.Context, _ string, r *Rule) error { + m.added = append(m.added, r) + return nil +} +func (m *syncDirRecorder) RemoveRule(_ context.Context, _ string, r *Rule) error { + m.removed = append(m.removed, r) + return nil +} + +// Sync must not churn a DirAny rule: whether the desired set lists it as one DirAny +// rule or as the two separate directions, mergeDirectionsCopy canonicalizes it to +// match the merged existing rule, so nothing is added or removed. +func TestSyncDirAnyIdempotent(t *testing.T) { + existing := func() []*Rule { + return []*Rule{{Direction: DirAny, Source: "1.2.3.4", Action: Accept}} + } + + // Desired lists the single DirAny rule. + m := &syncDirRecorder{existing: existing()} + added, removed, err := Sync(context.Background(), m, "", []*Rule{{Direction: DirAny, Source: "1.2.3.4", Action: Accept}}) + require.NoError(t, err) + require.Zero(t, added) + require.Zero(t, removed) + require.Empty(t, m.added) + require.Empty(t, m.removed) + + // Desired lists the two directions separately; they collapse to DirAny and match. + m2 := &syncDirRecorder{existing: existing()} + added, removed, err = Sync(context.Background(), m2, "", []*Rule{ + {Direction: DirInput, Source: "1.2.3.4", Action: Accept}, + {Direction: DirOutput, Destination: "1.2.3.4", Action: Accept}, + }) + require.NoError(t, err) + require.Zero(t, added, "a DirInput+DirOutput desired pair collapses to DirAny and must not churn") + require.Zero(t, removed) + require.Empty(t, m2.added) + require.Empty(t, m2.removed) +} + +func TestPortNeedsConcreteProtocol(t *testing.T) { + cases := []struct { + rule Rule + want bool + }{ + {Rule{Port: 80, Proto: TCP}, false}, + {Rule{Port: 80, Proto: UDP}, false}, + {Rule{Port: 80, Proto: ProtocolAny}, true}, + {Rule{Port: 0, Proto: ProtocolAny}, false}, + } + for _, c := range cases { + require.Equal(t, c.want, c.rule.PortNeedsConcreteProtocol(), + "PortNeedsConcreteProtocol(%+v)", c.rule) + } +} + +// HasPrefix is informational, not part of rule identity: two rules that differ +// only in HasPrefix compare equal, so it never affects dedup or removal. The same +// holds for NAT rules. +func TestEqualIgnoresHasPrefix(t *testing.T) { + a := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, HasPrefix: true} + b := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, HasPrefix: false} + require.True(t, a.Equal(b, true), "Rule.Equal must ignore HasPrefix") + require.True(t, a.EqualBase(b, true), "Rule.EqualBase must ignore HasPrefix") + + na := &NATRule{Kind: Masquerade, HasPrefix: true} + nb := &NATRule{Kind: Masquerade, HasPrefix: false} + require.True(t, na.Equal(nb), "NATRule.Equal must ignore HasPrefix") + require.True(t, na.EqualBase(nb), "NATRule.EqualBase must ignore HasPrefix") +} + +// isSetRef treats any non-empty Source/Destination token that is not an IP or +// CIDR (after an optional "!" negation) as an address-set reference. +func TestIsSetRef(t *testing.T) { + for _, a := range []string{"", "!", "1.2.3.4", "10.0.0.0/8", "!1.2.3.4", "2001:db8::1", "!2001:db8::/32"} { + require.False(t, isSetRef(a), "%q should not be a set reference", a) + } + for _, s := range []string{"myset", "!myset", "blocklist", "trusted_hosts"} { + require.True(t, isSetRef(s), "%q should be a set reference", s) + } +} + +// Number is informational like HasPrefix: two rules that differ only in their +// read-back ordering position compare equal, so it never affects dedup or removal. +func TestEqualIgnoresNumber(t *testing.T) { + a := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Number: 1} + b := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Number: 7} + require.True(t, a.Equal(b, true), "Rule.Equal must ignore Number") + require.True(t, a.EqualBase(b, true), "Rule.EqualBase must ignore Number") + + na := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Number: 1} + nb := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Number: 3} + require.True(t, na.Equal(nb), "NATRule.Equal must ignore Number") + require.True(t, na.EqualBase(nb), "NATRule.EqualBase must ignore Number") +} + +// The numbering helpers assign a rule's 1-based position within its ordering +// domain: per direction for chain-ordered backends, sequential for single-list +// backends, and per nat chain for NAT rules. +func TestNumberingHelpers(t *testing.T) { + byDir := []*Rule{{Port: 1}, {Port: 2, Direction: DirOutput}, {Port: 3}, {Port: 4, Direction: DirOutput}, + {Port: 5, Direction: DirForward}, {Port: 6, Direction: DirForward}} + numberByDirection(byDir) + require.Equal(t, []int{1, 1, 2, 2, 1, 2}, + []int{byDir[0].Number, byDir[1].Number, byDir[2].Number, byDir[3].Number, byDir[4].Number, byDir[5].Number}, + "input, output and forward chains are numbered independently from 1") + + seq := []*Rule{{Port: 1}, {Port: 2, Direction: DirOutput}, {Port: 3}} + numberSequential(seq) + require.Equal(t, []int{1, 2, 3}, []int{seq[0].Number, seq[1].Number, seq[2].Number}, + "a single ordered list numbers across directions") + + nat := []*NATRule{{Kind: DNAT}, {Kind: SNAT}, {Kind: Redirect}, {Kind: Masquerade}} + numberNATByChain(nat) + require.Equal(t, []int{1, 1, 2, 2}, []int{nat[0].Number, nat[1].Number, nat[2].Number, nat[3].Number}, + "prerouting (dnat/redirect) and postrouting (snat/masquerade) number independently") + + natSeq := []*NATRule{{Kind: DNAT}, {Kind: SNAT}, {Kind: Redirect}} + numberNATSequential(natSeq) + require.Equal(t, []int{1, 2, 3}, []int{natSeq[0].Number, natSeq[1].Number, natSeq[2].Number}) +} + +// A backend that edits a physical, merge-collapsed list (nft chains, pf's anchor, +// ufw's numbered list) must translate a caller's merged position into the physical +// index of that logical rule's anchor row. The naive position-1 lands too early — +// inside a merged IPv4/IPv6 pair — once such a pair precedes the target. +func TestMergedInsertIndex(t *testing.T) { + // Raw (unmerged) list: an ssh IPv4/IPv6 pair that mergeFamilies collapses into + // one logical rule, followed by an IPv4-only https rule. + raw := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, + {Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, + {Family: IPv4, Proto: TCP, Port: 443, Action: Accept}, + } + anchors := mergedFamilyAnchors(raw) + require.Equal(t, []int{0, 2}, anchors, "ssh pair collapses to anchor 0; https is anchor 2") + + // Insert before the https rule, which GetRules numbers as merged position 2. + // The physical index must be 2 (before the https row), not the naive 1 (which + // would split the ssh IPv4/IPv6 pair). + require.Equal(t, 2, mergedInsertIndex(anchors, len(raw), 2)) + require.NotEqual(t, 2-1, mergedInsertIndex(anchors, len(raw), 2), + "the merged position must not be used as a raw index") + + // Position 1 prepends (physical index 0); a position past the end appends. + require.Equal(t, 0, mergedInsertIndex(anchors, len(raw), 1)) + require.Equal(t, len(raw), mergedInsertIndex(anchors, len(raw), 3)) + require.Equal(t, len(raw), mergedInsertIndex(anchors, len(raw), 99)) + require.Equal(t, 0, mergedInsertIndex(anchors, len(raw), 0), "a non-positive position prepends") + + // With no merged pair, every row is its own anchor and the mapping is identity. + flat := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, + {Family: IPv4, Proto: TCP, Port: 80, Action: Accept}, + {Family: IPv4, Proto: TCP, Port: 443, Action: Accept}, + } + fa := mergedFamilyAnchors(flat) + for pos := 1; pos <= len(flat); pos++ { + require.Equal(t, pos-1, mergedInsertIndex(fa, len(flat), pos)) + } +} + +// 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") +} + +// Family is compared through impliedFamily, so a rule left FamilyAny matches the +// concrete family its content forces (an ICMP proto, or a concrete-family +// address) — the family a backend stores and lists it back under. +func TestEqualImpliedFamily(t *testing.T) { + // A FamilyAny ICMP rule equals the IPv4 rule a backend reads back. + anyICMP := &Rule{Proto: ICMP, Action: Accept} + v4ICMP := &Rule{Family: IPv4, Proto: ICMP, Action: Accept} + require.True(t, anyICMP.Equal(v4ICMP, true), "FamilyAny icmp must equal IPv4 icmp") + + // A FamilyAny rule with a v4 address equals its IPv4 read-back. + anyAddr := &Rule{Source: "10.0.0.1", Proto: TCP, Port: 22, Action: Accept} + v4Addr := &Rule{Family: IPv4, Source: "10.0.0.1", Proto: TCP, Port: 22, Action: Accept} + require.True(t, anyAddr.Equal(v4Addr, true), "FamilyAny address rule must equal its IPv4 form") + + // A genuinely family-agnostic rule (no address, no ICMP) stays distinct from a + // single-family one. + anyTCP := &Rule{Proto: TCP, Port: 22, Action: Accept} + v4TCP := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept} + require.False(t, anyTCP.Equal(v4TCP, true), "an addressless FamilyAny rule is not a single-family rule") + require.False(t, v4ICMP.Equal(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}, true)) +} + +// NATRule.EqualBase must canonicalize ToAddress the same way it does Source and +// Destination, so a translation target re-spelled by a backend (an IPv6 zero- +// compressed/lower-cased address, a /32 host prefix) still dedups and removes. +func TestNATRuleEqualBaseCanonicalizesToAddress(t *testing.T) { + a := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:DB8::1", ToPort: 8080} + b := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080} + require.True(t, a.EqualBase(b), "differing IPv6 spellings of ToAddress are the same NAT rule") + + c := &NATRule{Kind: SNAT, Proto: TCP, ToAddress: "1.2.3.4"} + d := &NATRule{Kind: SNAT, Proto: TCP, ToAddress: "1.2.3.4/32"} + require.True(t, c.EqualBase(d), "a /32 host and its bare form are the same translation target") + + require.False(t, a.EqualBase(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::2", ToPort: 8080})) +} + +// NATRule.Equal compares family through impliedFamily, so a FamilyAny rule +// matches the concrete family a backend stores it under (mirroring Rule.Equal). +func TestNATRuleEqualImpliedFamily(t *testing.T) { + anyRule := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2", ToPort: 80} + v4Rule := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "192.168.1.2", ToPort: 80} + require.True(t, anyRule.Equal(v4Rule), "a FamilyAny DNAT equals the IPv4 rule its target implies") +} + +// NATRule.validate must reject a translation port on a rule without a port- +// bearing protocol: iptables' DNAT/REDIRECT/SNAT and nft's dnat/redirect reject +// a target port without tcp/udp/sctp, so it must not marshal such a rule. +func TestNATRuleValidate(t *testing.T) { + require.NoError(t, (&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}).validate()) + require.NoError(t, (&NATRule{Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080}).validate()) + require.NoError(t, (&NATRule{Kind: SNAT, ToAddress: "1.2.3.4"}).validate()) + require.NoError(t, (&NATRule{Kind: Masquerade}).validate()) + + // DNAT needs a translation address. + require.Error(t, (&NATRule{Kind: DNAT}).validate()) + // Redirect needs a port and no address. + require.Error(t, (&NATRule{Kind: Redirect}).validate()) + require.Error(t, (&NATRule{Kind: Redirect, ToPort: 80, ToAddress: "1.2.3.4"}).validate()) + // SNAT needs an address and no port. + require.Error(t, (&NATRule{Kind: SNAT}).validate()) + require.Error(t, (&NATRule{Kind: SNAT, ToAddress: "1.2.3.4", ToPort: 80}).validate()) + // Masquerade takes no target. + require.Error(t, (&NATRule{Kind: Masquerade, ToAddress: "1.2.3.4"}).validate()) + // A match port without a concrete protocol is rejected. + require.Error(t, (&NATRule{Kind: DNAT, Port: 80, ToAddress: "1.2.3.4"}).validate()) + + // A translation port (ToPort) also needs a concrete protocol. + require.Error(t, (&NATRule{Kind: Redirect, ToPort: 8080}).validate()) + require.NoError(t, (&NATRule{Kind: Redirect, Proto: TCP, ToPort: 8080}).validate()) + require.Error(t, (&NATRule{Kind: DNAT, ToAddress: "1.2.3.4", ToPort: 80}).validate()) + require.NoError(t, (&NATRule{Kind: DNAT, Proto: UDP, ToAddress: "1.2.3.4", ToPort: 80}).validate()) + require.NoError(t, (&NATRule{Kind: DNAT, ToAddress: "1.2.3.4"}).validate()) +} + +// Priority is part of rule identity: two rules differing only in priority are +// distinct, so a reconcile can change a rule's priority (firewalld rich rules). +func TestEqualDistinguishesPriority(t *testing.T) { + a := &Rule{Priority: 10, Family: IPv4, Proto: TCP, Port: 22, Action: Accept} + b := &Rule{Priority: 20, Family: IPv4, Proto: TCP, Port: 22, Action: Accept} + require.False(t, a.Equal(b, false), "rules differing only in priority must not be equal") + require.False(t, a.EqualBase(b, false)) +} + +// Direction is part of rule identity when the backend honors it, and collapses +// when it does not — so a forward rule is distinct from its input/output twins on +// an ordered backend but folds into them on a direction-agnostic one. +func TestEqualDistinguishesDirection(t *testing.T) { + in := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept} + out := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Direction: DirOutput} + fwd := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Direction: DirForward} + + // Honored: every direction is a distinct rule. + require.False(t, in.Equal(out, true), "input and output must differ when direction is honored") + require.False(t, in.Equal(fwd, true), "input and forward must differ when direction is honored") + require.False(t, out.Equal(fwd, true), "output and forward must differ when direction is honored") + + // Not honored: direction is ignored, so all three compare equal. + require.True(t, in.Equal(fwd, false), "direction is ignored when not honored") + require.True(t, out.Equal(fwd, false), "direction is ignored when not honored") + + // EqualBase ignores family but still honors direction when asked. + require.False(t, in.EqualBase(fwd, true)) + require.True(t, in.EqualBase(fwd, false)) +} + +// ErrUnsupportedForward is a member of the ErrUnsupported family so a caller can +// tell a genuinely unexpressible forward rule from a real failure. +func TestErrUnsupportedForward(t *testing.T) { + require.ErrorIs(t, ErrUnsupportedForward, ErrUnsupported) + require.ErrorIs(t, unsupportedForward("test"), ErrUnsupportedForward) + require.ErrorIs(t, unsupportedForward("test"), ErrUnsupported) +} + +func TestMergeFamilies(t *testing.T) { + // A genuine IPv4/IPv6 pair that is otherwise identical collapses to one + // FamilyAny rule. + merged := mergeFamilies([]*Rule{ + {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, + {Family: IPv6, Port: 80, Proto: TCP, Action: Accept}, + }) + require.Len(t, merged, 1, "expected a v4/v6 pair to merge into 1 rule") + require.Equal(t, FamilyAny, merged[0].Family, "expected merged rule to be FamilyAny") + + // Two identical same-family rules must NOT be collapsed into FamilyAny; a + // duplicate within a single family is not cross-family coverage. + merged = mergeFamilies([]*Rule{ + {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, + {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, + }) + for _, r := range merged { + require.NotEqual(t, FamilyAny, r.Family, + "same-family duplicate was wrongly merged into FamilyAny: %+v", merged) + } +} + +// DirInput must remain the zero value and "any"/"both" must parse to DirAny so a +// caller and the JSON/backup round-trip agree on the new direction. +func TestParseDirectionAny(t *testing.T) { + require.Equal(t, DirInput, Direction(0), "DirInput must stay the zero value") + require.Equal(t, "any", DirAny.String()) + for _, tok := range []string{"any", "Any", "both", "BOTH"} { + d, err := ParseDirection(tok) + require.NoErrorf(t, err, "ParseDirection(%q)", tok) + require.Equalf(t, DirAny, d, "ParseDirection(%q)", tok) + } + // Round-trip every direction through String -> ParseDirection. + for _, d := range []Direction{DirInput, DirOutput, DirForward, DirAny} { + got, err := ParseDirection(d.String()) + require.NoError(t, err) + require.Equal(t, d, got, "direction %v must round-trip through its string", d) + } +} + +// directionSwapped swaps exactly the source/destination-bound fields and leaves +// every protocol/policy-bound field untouched. +func TestDirectionSwapped(t *testing.T) { + in := &Rule{ + Direction: DirInput, Family: IPv4, Proto: TCP, + Source: "10.0.0.1", Destination: "10.0.0.2", + Port: 22, SourcePort: 1024, + Ports: []PortRange{{Start: 80, End: 90}}, + SourcePorts: []PortRange{{Start: 5000, End: 5000}}, + InInterface: "eth0", OutInterface: "eth1", + State: StateEstablished, Action: Accept, ICMPType: Ptr[uint8](8), + Log: true, LogPrefix: "x", Comment: "c", + } + s := in.directionSwapped() + require.Equal(t, "10.0.0.2", s.Source) + require.Equal(t, "10.0.0.1", s.Destination) + require.Equal(t, uint16(1024), s.Port) + require.Equal(t, uint16(22), s.SourcePort) + require.Equal(t, in.SourcePorts, s.Ports) + require.Equal(t, in.Ports, s.SourcePorts) + require.Equal(t, "eth1", s.InInterface) + require.Equal(t, "eth0", s.OutInterface) + // Direction-independent fields are preserved. + require.Equal(t, in.Proto, s.Proto) + require.Equal(t, in.State, s.State) + require.Equal(t, in.Action, s.Action) + require.Equal(t, in.ICMPType, s.ICMPType) + require.Equal(t, in.Log, s.Log) + require.Equal(t, in.Comment, s.Comment) + // The original is not mutated (shallow copy). + require.Equal(t, "10.0.0.1", in.Source) + // Swap is an involution. + require.True(t, in.EqualBase(s.directionSwapped(), true), "swapping twice returns the original") +} + +// coversDirection (asymmetric, add/dedup) and coversDirectionRemoval (symmetric): +// DirAny spans input+output, never forward; !honored means direction never gates. +func TestCoversDirection(t *testing.T) { + // Asymmetric: an existing rule in `have` covers a caller in `want`. + require.True(t, coversDirection(DirAny, DirInput, true)) + require.True(t, coversDirection(DirAny, DirOutput, true)) + require.False(t, coversDirection(DirAny, DirForward, true), "DirAny never covers forward") + require.True(t, coversDirection(DirInput, DirInput, true)) + require.False(t, coversDirection(DirInput, DirOutput, true)) + require.False(t, coversDirection(DirInput, DirAny, true), "a concrete direction must not cover DirAny") + require.True(t, coversDirection(DirInput, DirOutput, false), "!honored: direction never gates") + + // Symmetric: DirAny on either side touches a concrete direction. + require.True(t, coversDirectionRemoval(DirInput, DirAny, true)) + require.True(t, coversDirectionRemoval(DirAny, DirOutput, true)) + require.False(t, coversDirectionRemoval(DirInput, DirOutput, true)) + require.False(t, coversDirectionRemoval(DirAny, DirForward, true)) + require.True(t, coversDirectionRemoval(DirForward, DirForward, true)) +} + +// mergeDirections collapses an input rule and its role-swapped output twin into one +// DirAny rule, honoring family and never merging a forward rule. +func TestMergeDirections(t *testing.T) { + // A csf-style host allow: inbound Source=X + outbound Destination=X collapse to + // one DirAny rule in the inbound frame (Source=X). + merged := mergeDirections([]*Rule{ + {Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Action: Accept}, + {Direction: DirOutput, Family: IPv4, Destination: "1.2.3.4", Action: Accept}, + }) + require.Len(t, merged, 1, "in+out host allow must merge to 1 rule") + require.Equal(t, DirAny, merged[0].Direction) + require.Equal(t, "1.2.3.4", merged[0].Source) + require.Empty(t, merged[0].Destination) + + // A ported service both ways: dport 22 inbound pairs with sport 22 outbound. + merged = mergeDirections([]*Rule{ + {Direction: DirInput, Proto: TCP, Port: 22, Action: Accept}, + {Direction: DirOutput, Proto: TCP, SourcePort: 22, Action: Accept}, + }) + require.Len(t, merged, 1, "dport-in + sport-out must merge") + require.Equal(t, DirAny, merged[0].Direction) + require.Equal(t, uint16(22), merged[0].Port) + + // A forward rule must never merge, even if a swap-equal partner exists. + merged = mergeDirections([]*Rule{ + {Direction: DirForward, Proto: TCP, Port: 22, Action: Accept}, + {Direction: DirOutput, Proto: TCP, SourcePort: 22, Action: Accept}, + }) + require.Len(t, merged, 2, "forward rules never participate in the direction merge") + + // A cross-family pair must not merge across direction (v4-in with v6-out). + merged = mergeDirections([]*Rule{ + {Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, + {Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept}, + }) + require.Len(t, merged, 2, "an opposite-family in/out pair must not merge") +} + +// A rule present as {v4-in, v6-in, v4-out, v6-out} collapses, after mergeFamilies +// then mergeDirections, to a single FamilyAny + DirAny rule. +func TestMergeFamiliesThenDirectionsFourRows(t *testing.T) { + rows := []*Rule{ + {Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, + {Direction: DirInput, Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, + {Direction: DirOutput, Family: IPv4, Proto: TCP, SourcePort: 22, Action: Accept}, + {Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept}, + } + rows = mergeFamilies(rows) + numberByDirection(rows) + rows = mergeDirections(rows) + require.Len(t, rows, 1, "the 4-cell rule must collapse to one") + require.Equal(t, FamilyAny, rows[0].Family) + require.Equal(t, DirAny, rows[0].Direction) + require.Equal(t, uint16(22), rows[0].Port) +} + +// A rule that is symmetric under the swap (Source==Destination, no ports) still +// merges its in/out pair, and the surviving DirAny rule is unchanged by the swap. +func TestMergeDirectionsSymmetric(t *testing.T) { + merged := mergeDirections([]*Rule{ + {Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Destination: "1.2.3.4", Action: Accept}, + {Direction: DirOutput, Family: IPv4, Source: "1.2.3.4", Destination: "1.2.3.4", Action: Accept}, + }) + require.Len(t, merged, 1) + require.Equal(t, DirAny, merged[0].Direction) +} + +// expandDirections fans a DirAny rule into an inbound row plus a swapped outbound +// row, and leaves a concrete-direction rule alone. +func TestExpandDirections(t *testing.T) { + rows := expandDirections(&Rule{Direction: DirAny, Family: IPv4, Source: "1.2.3.4", Port: 22, Proto: TCP, Action: Accept}) + require.Len(t, rows, 2) + require.Equal(t, DirInput, rows[0].Direction) + require.Equal(t, "1.2.3.4", rows[0].Source) + require.Equal(t, uint16(22), rows[0].Port) + require.Equal(t, DirOutput, rows[1].Direction) + require.Equal(t, "1.2.3.4", rows[1].Destination) + require.Equal(t, uint16(22), rows[1].SourcePort) + + single := expandDirections(&Rule{Direction: DirInput, Port: 80, Action: Accept}) + require.Len(t, single, 1) + require.Equal(t, DirInput, single[0].Direction) +} + +// splitDualRowDirection returns the surviving-direction rule when one direction of +// a genuine DirAny row is removed, and nil for a concrete matched row or a +// direction-agnostic target. +func TestSplitDualRowDirection(t *testing.T) { + any := &Rule{Direction: DirAny, Family: IPv4, Source: "1.2.3.4", Action: Accept} + // Remove the input cell: the output survives, in its natural (destination) frame. + surv := splitDualRowDirection(any, &Rule{Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Action: Accept}) + require.NotNil(t, surv) + require.Equal(t, DirOutput, surv.Direction) + require.Equal(t, "1.2.3.4", surv.Destination) + require.Empty(t, surv.Source) + // Remove the output cell: the input survives unchanged. + surv = splitDualRowDirection(any, &Rule{Direction: DirOutput, Family: IPv4, Destination: "1.2.3.4", Action: Accept}) + require.NotNil(t, surv) + require.Equal(t, DirInput, surv.Direction) + require.Equal(t, "1.2.3.4", surv.Source) + // A concrete matched row never splits (its twin is a separate physical row). + require.Nil(t, splitDualRowDirection(&Rule{Direction: DirInput, Source: "1.2.3.4"}, &Rule{Direction: DirInput, Source: "1.2.3.4"})) + // A direction-agnostic target removes the whole rule (no survivor). + require.Nil(t, splitDualRowDirection(any, &Rule{Direction: DirAny, Source: "1.2.3.4"})) +} + +// A DirAny row must be found by a concrete-direction removal target and vice versa, +// through EqualForRemoval, honoring the swap. And a DirAny existing rule dedups a +// concrete add. +func TestEqualForDirectionCoverage(t *testing.T) { + anyRow := &Rule{Direction: DirAny, Family: IPv4, Source: "1.2.3.4", Action: Accept} + inTarget := &Rule{Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Action: Accept} + outTarget := &Rule{Direction: DirOutput, Family: IPv4, Destination: "1.2.3.4", Action: Accept} + require.True(t, anyRow.EqualForRemoval(inTarget, true), "a DirAny row matches an input target") + require.True(t, anyRow.EqualForRemoval(outTarget, true), "a DirAny row matches an output target (swapped)") + require.True(t, anyRow.EqualForDedup(inTarget, true), "a DirAny row dedups an input add") + require.True(t, anyRow.EqualForDedup(outTarget, true), "a DirAny row dedups an output add (swapped)") + // A concrete input row must NOT dedup a DirAny add (it would leave output uncovered). + require.False(t, inTarget.EqualForDedup(anyRow, true), "an input row must not cover a DirAny add") + // A forward target must not be touched by a DirAny row. + require.False(t, anyRow.EqualForRemoval(&Rule{Direction: DirForward, Family: IPv4, Source: "1.2.3.4", Action: Accept}, true)) +} + +// dirAnyInputFallback degrades a DirAny rule to its input half on a backend with no +// output concept, and leaves every other case unchanged. +func TestDirAnyInputFallback(t *testing.T) { + any := &Rule{Direction: DirAny, Source: "1.2.3.4", Action: Accept} + // No output support: DirAny collapses to DirInput, fields preserved, original + // untouched. + got := dirAnyInputFallback(any, false) + require.Equal(t, DirInput, got.Direction) + require.Equal(t, "1.2.3.4", got.Source) + require.Equal(t, DirAny, any.Direction, "the input rule must not be mutated") + // Output support: DirAny is left alone (the backend fans it out instead). + require.Equal(t, DirAny, dirAnyInputFallback(any, true).Direction) + // A concrete-direction rule is always returned unchanged. + in := &Rule{Direction: DirInput, Source: "1.2.3.4", Action: Accept} + require.Same(t, in, dirAnyInputFallback(in, false)) +} + +// 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) + } +} + +// An unknown enum token in a backup fails to decode rather than silently +// becoming a wrong value. +func TestEnumJSONUnknownRejects(t *testing.T) { + var a Action + err := json.Unmarshal([]byte(`"bogus"`), &a) + require.Error(t, err) +} + +// 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) +} + +// WriteBackup rejects a nil backup; ReadBackup rejects malformed input. +func TestBackupJSONErrors(t *testing.T) { + require.Error(t, WriteBackup(&bytes.Buffer{}, nil)) + + _, err := ReadBackup(strings.NewReader("not json")) + require.Error(t, err) +} + +// restoreRecorder is a minimal Manager whose Restore captures the backup handed +// to it, so RestoreReader can be exercised without a live firewall. +type restoreRecorder struct { + restored *Backup + restore func(*Backup) error +} + +func (m *restoreRecorder) Type() string { return "recorder" } +func (m *restoreRecorder) Capabilities() Capabilities { + return Capabilities{Output: true} +} +func (m *restoreRecorder) GetZone(context.Context, string) (string, error) { return "", nil } +func (m *restoreRecorder) GetRules(context.Context, string) ([]*Rule, error) { + return nil, nil +} +func (m *restoreRecorder) AddRule(context.Context, string, *Rule) error { return nil } +func (m *restoreRecorder) InsertRule(context.Context, string, int, *Rule) error { return nil } +func (m *restoreRecorder) MoveRule(context.Context, string, *Rule, int) error { return nil } +func (m *restoreRecorder) RemoveRule(context.Context, string, *Rule) error { return nil } +func (m *restoreRecorder) GetNATRules(context.Context, string) ([]*NATRule, error) { + return nil, nil +} +func (m *restoreRecorder) AddNATRule(context.Context, string, *NATRule) error { return nil } +func (m *restoreRecorder) InsertNATRule(context.Context, string, int, *NATRule) error { return nil } +func (m *restoreRecorder) RemoveNATRule(context.Context, string, *NATRule) error { return nil } +func (m *restoreRecorder) Backup(context.Context, string) (*Backup, error) { return nil, nil } +func (m *restoreRecorder) Restore(_ context.Context, _ string, b *Backup) error { + m.restored = b + if m.restore != nil { + return m.restore(b) + } + return nil +} +func (m *restoreRecorder) GetDefaultPolicy(context.Context, string) (*DefaultPolicy, error) { + return nil, nil +} +func (m *restoreRecorder) SetDefaultPolicy(context.Context, string, *DefaultPolicy) error { + return nil +} +func (m *restoreRecorder) GetAddressSets(context.Context) ([]*AddressSet, error) { + return nil, nil +} +func (m *restoreRecorder) GetAddressSet(context.Context, string) (*AddressSet, error) { + return nil, nil +} +func (m *restoreRecorder) AddAddressSet(context.Context, *AddressSet) error { return nil } +func (m *restoreRecorder) RemoveAddressSet(context.Context, string) error { return nil } +func (m *restoreRecorder) AddAddressSetEntry(context.Context, string, string) error { + return nil +} +func (m *restoreRecorder) RemoveAddressSetEntry(context.Context, string, string) error { + return nil +} +func (m *restoreRecorder) Reload(context.Context) error { return nil } +func (m *restoreRecorder) Close(context.Context) error { return nil } + +// RestoreReader decodes a backup from the reader and hands it to Restore. A read +// error surfaces before Restore is ever called; a good payload reaches Restore +// intact. +func TestRestoreReader(t *testing.T) { + ctx := context.Background() + + // A malformed reader: RestoreReader returns the read error without + // invoking Restore. + rec := &restoreRecorder{} + err := RestoreReader(ctx, rec, "", strings.NewReader("not json")) + require.Error(t, err) + require.Nil(t, rec.restored, "Restore must not run on a read error") + + // A valid payload: Restore receives the decoded backup. + src := &Backup{Rules: []*Rule{{Family: IPv4, Port: 22, Proto: TCP, Action: Accept}}} + var buf bytes.Buffer + require.NoError(t, WriteBackup(&buf, src)) + + rec = &restoreRecorder{} + require.NoError(t, RestoreReader(ctx, rec, "public", &buf)) + require.NotNil(t, rec.restored) + require.Len(t, rec.restored.Rules, 1) + require.Equal(t, uint16(22), rec.restored.Rules[0].Port) +} + +// --- Bug-report fixes: core library ---------------------------------------- + +// ParseAction rejects the "invalid" sentinel as caller input so a rule or policy +// can never be authored with no real action, while Action.UnmarshalJSON still +// round-trips it for backup fidelity. +func TestParseActionRejectsInvalidSentinel(t *testing.T) { + for _, tok := range []string{"invalid", "INVALID", " invalid ", "bogus", ""} { + _, err := ParseAction(tok) + require.Error(t, err, "ParseAction(%q) must error", tok) + } + for tok, want := range map[string]Action{"accept": Accept, "reject": Reject, "drop": Drop} { + got, err := ParseAction(tok) + require.NoError(t, err) + require.Equal(t, want, got) + } + + // The wire form of ActionInvalid still decodes, so a backup carrying it + // round-trips rather than failing to load. + var a Action + require.NoError(t, json.Unmarshal([]byte(`"invalid"`), &a)) + require.Equal(t, ActionInvalid, a) + // An unknown token is still rejected on the wire. + require.Error(t, json.Unmarshal([]byte(`"bogus"`), &a)) +} + +// 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) + } +} + +// Protocol.UnmarshalJSON rejects an unknown protocol string rather than silently +// widening the rule to ProtocolAny; only "any" and empty resolve to ProtocolAny. +func TestProtocolUnmarshalRejectsUnknown(t *testing.T) { + var p Protocol + require.NoError(t, json.Unmarshal([]byte(`"tcp"`), &p)) + require.Equal(t, TCP, p) + require.NoError(t, json.Unmarshal([]byte(`"any"`), &p)) + require.Equal(t, ProtocolAny, p) + require.Error(t, json.Unmarshal([]byte(`"tpc"`), &p), "an unknown protocol must not decode") + require.Error(t, json.Unmarshal([]byte(`"bogus"`), &p)) +} + +// TestMergedNATInsertIndexMasqueradePair verifies the merged-position mapping an +// ordered NAT backend (pf, nft) uses for InsertNATRule: GetNATRules merges an +// IPv4/IPv6 masquerade pair into one logical rule, so a caller-supplied position +// must map back to the physical row before which to insert. Without the mapping a +// plain position-1 would splice a new rule into the middle of the merged pair. +func TestMergedNATInsertIndexMasqueradePair(t *testing.T) { + // Physical anchor order: masquerade IPv4, its IPv6 twin, then a DNAT rule. + masqV4 := &NATRule{Kind: Masquerade, Interface: "em0", Family: IPv4} + masqV6 := &NATRule{Kind: Masquerade, Interface: "em0", Family: IPv6} + dnat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", Family: IPv4} + physical := []*NATRule{masqV4, masqV6, dnat} + + // GetNATRules collapses the masquerade pair, so it reports masq=Number 1, + // dnat=Number 2. Inserting at merged position 2 means "before the DNAT rule". + anchors := mergedNATFamilyAnchors(physical) + require.Equal(t, []int{0, 2}, anchors, "the masquerade twin at index 1 is absorbed") + + idx := mergedInsertIndex(anchors, len(physical), 2) + require.Equal(t, 2, idx, "position 2 must map before the DNAT row (physical index 2), not split the masq pair") + + // Position 1 maps before the masquerade anchor; a position past the end appends. + require.Equal(t, 0, mergedInsertIndex(anchors, len(physical), 1)) + require.Equal(t, len(physical), mergedInsertIndex(anchors, len(physical), 3)) +} + +// TestEqualForDedupFamily locks the add-time dedup family-cover relation: an +// existing FamilyAny rule covers either family, and a concrete-family rule covers +// only its own. Without this, a base-equal existing rule of one family would +// wrongly suppress the add of its opposite-family twin (see the nft/csf +// cross-family bugs). Rules differ only in Family so the base compare always +// passes and the family relation is what is under test. +func TestEqualForDedupFamily(t *testing.T) { + base := func(fam Family) *Rule { return &Rule{Family: fam, Proto: TCP, Port: 22, Action: Drop} } + require.True(t, base(FamilyAny).EqualForDedup(base(IPv4), true), "FamilyAny covers IPv4") + require.True(t, base(FamilyAny).EqualForDedup(base(IPv6), true), "FamilyAny covers IPv6") + require.True(t, base(FamilyAny).EqualForDedup(base(FamilyAny), true), "FamilyAny covers FamilyAny") + require.True(t, base(IPv4).EqualForDedup(base(IPv4), true), "IPv4 covers IPv4") + require.True(t, base(IPv6).EqualForDedup(base(IPv6), true), "IPv6 covers IPv6") + require.False(t, base(IPv4).EqualForDedup(base(IPv6), true), "IPv4 must not cover IPv6") + require.False(t, base(IPv6).EqualForDedup(base(IPv4), true), "IPv6 must not cover IPv4") + require.False(t, base(IPv4).EqualForDedup(base(FamilyAny), true), "a concrete family must not cover FamilyAny") + // A rule whose base fields differ is never a duplicate, whatever the family. + require.False(t, base(FamilyAny).EqualForDedup(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop}, true), + "a different base rule is not a duplicate") +} + +// syncStateMock returns a fixed existing set and records the removes/adds Sync +// issues, so the diff can be exercised against a pre-populated firewall. +type syncStateMock struct { + restoreRecorder + existing []*Rule + added []*Rule + removed []*Rule +} + +func (m *syncStateMock) GetRules(context.Context, string) ([]*Rule, error) { return m.existing, nil } +func (m *syncStateMock) AddRule(_ context.Context, _ string, r *Rule) error { + m.added = append(m.added, r) + return nil +} +func (m *syncStateMock) RemoveRule(_ context.Context, _ string, r *Rule) error { + m.removed = append(m.removed, r) + return nil +} + +// Sync must not churn when GetRules has merged an IPv4/IPv6 pair into one +// FamilyAny rule but the caller lists the two families separately: the merged +// existing rule already covers both desired twins, so nothing is added or removed. +func TestSyncMergedFamilyNoChurn(t *testing.T) { + merged := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop} + m := &syncStateMock{existing: []*Rule{merged}} + desired := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Drop}, + {Family: IPv6, Proto: TCP, Port: 22, Action: Drop}, + } + added, removed, err := Sync(context.Background(), m, "", desired) + require.NoError(t, err) + require.Equal(t, 0, removed, "the merged existing rule covers both twins; nothing removed") + require.Equal(t, 0, added, "the merged existing rule covers both twins; nothing added") + require.Empty(t, m.removed) + require.Empty(t, m.added) + require.NotNil(t, desired[0], "Sync must not mutate the caller's desired slice entries") + require.Equal(t, IPv4, desired[0].Family, "Sync must not mutate the caller's desired rules") +} + +// TestEqualForRemovalFamily: a FamilyAny target (a merged rule) matches every +// row, a FamilyAny row matches any target, and concrete families must match +// otherwise. The receiver is the existing row; the argument is the target. +func TestEqualForRemovalFamily(t *testing.T) { + base := func(fam Family) *Rule { return &Rule{Family: fam, Proto: TCP, Port: 22, Action: Drop} } + require.True(t, base(IPv4).EqualForRemoval(base(FamilyAny), true), "a FamilyAny target matches an IPv4 row") + require.True(t, base(IPv6).EqualForRemoval(base(FamilyAny), true), "a FamilyAny target matches an IPv6 row") + require.True(t, base(FamilyAny).EqualForRemoval(base(IPv4), true), "an IPv4 target matches a FamilyAny row") + require.True(t, base(IPv4).EqualForRemoval(base(IPv4), true)) + require.False(t, base(IPv6).EqualForRemoval(base(IPv4), true), "an IPv4 target must not touch an IPv6 row") + require.False(t, base(IPv4).EqualForRemoval(base(IPv6), true), "an IPv6 target must not touch an IPv4 row") +} + +// The matcher pf and nft RemoveRule use — EqualForRemoval — must find a merged +// FamilyAny rule against both concrete physical rows, and a concrete-family +// target must match only its own row. +func TestMergedFamilyMatcherFindsBothRows(t *testing.T) { + v4 := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept} + v6 := &Rule{Family: IPv6, Proto: TCP, Port: 22, Action: Accept} + merged := mergeFamilies([]*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, + {Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, + }) + require.Len(t, merged, 1) + require.Equal(t, FamilyAny, merged[0].impliedFamily()) + // A merged (FamilyAny) target matches both rows. + require.True(t, v4.EqualForRemoval(merged[0], true)) + require.True(t, v6.EqualForRemoval(merged[0], true)) + // A concrete IPv4 target matches only the IPv4 row. + require.True(t, v4.EqualForRemoval(v4, true)) + require.False(t, v6.EqualForRemoval(v4, true)) +} + +// TestSplitDualRow: a concrete-family removal against a genuine dual-family row +// yields the opposite-family rule to re-add, preserving the untargeted family; +// every other combination yields nil (no split). +func TestSplitDualRow(t *testing.T) { + dual := &Rule{Family: FamilyAny, Proto: TCP, Port: 3493, Action: Accept, Comment: "keep"} + + // Removing IPv4 from a dual row leaves an IPv6 copy of the whole rule. + got := splitDualRow(dual, &Rule{Family: IPv4, Proto: TCP, Port: 3493, Action: Accept}) + require.NotNil(t, got) + require.Equal(t, IPv6, got.Family) + require.Equal(t, "keep", got.Comment, "the surviving family keeps the original rule's fields") + require.Equal(t, FamilyAny, dual.Family, "the matched row is not mutated") + + // Removing IPv6 from a dual row leaves an IPv4 copy. + got = splitDualRow(dual, &Rule{Family: IPv6, Proto: TCP, Port: 3493, Action: Accept}) + require.NotNil(t, got) + require.Equal(t, IPv4, got.Family) + + // A FamilyAny target removes the whole rule — no split. + require.Nil(t, splitDualRow(dual, &Rule{Family: FamilyAny, Proto: TCP, Port: 3493, Action: Accept})) + + // A concrete-family matched row removes only itself — no split. + concrete := &Rule{Family: IPv4, Proto: TCP, Port: 3493, Action: Accept} + require.Nil(t, splitDualRow(concrete, &Rule{Family: IPv4, Proto: TCP, Port: 3493, Action: Accept})) + + require.Equal(t, IPv6, oppositeFamily(IPv4)) + require.Equal(t, IPv4, oppositeFamily(IPv6)) + require.Equal(t, FamilyAny, oppositeFamily(FamilyAny)) +} + +// eqRateLimit treats an explicit netfilter default burst (5) as equal to an unset +// burst (0), since nft/iptables read the default back as 0; a non-default burst +// stays distinct. +func TestEqRateLimitBurstDefault(t *testing.T) { + a := &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5} + b := &RateLimit{Rate: 10, Unit: PerMinute, Burst: 0} + require.True(t, eqRateLimit(a, b), "an explicit burst of 5 equals the default (0)") + c := &RateLimit{Rate: 10, Unit: PerMinute, Burst: 3} + require.False(t, eqRateLimit(a, c), "a non-default burst stays distinct") +} + +// runCommand must pin LC_ALL=C so backend tools emit canonical English output and +// error strings, which several backends match to drive fallback logic (ufw's +// insert-append and idempotent-remove, CSF/APF restart handling). Guards the +// locale pinning in runCommandStdin against regression on a translated host. +func TestRunCommandPinsCLocale(t *testing.T) { + out, err := runCommand(context.Background(), "sh", "-c", "printf %s \"$LC_ALL\"") + require.NoError(t, err) + require.Equal(t, []string{"C"}, out, "runCommand must export LC_ALL=C to the child") +} + +// 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) + } +} + +// coalescePortRanges yields the minimal sorted canonical list, so the merged +// form a backend reads back and the split form a caller wrote reduce to the same +// value. +func TestCoalescePortRanges(t *testing.T) { + require.Nil(t, coalescePortRanges(nil)) + require.Equal(t, + []PortRange{{22, 30}}, + coalescePortRanges([]PortRange{{24, 30}, {22, 22}, {23, 23}}), + "contiguous ranges collapse to one span") + require.Equal(t, + []PortRange{{80, 80}, {443, 443}}, + coalescePortRanges([]PortRange{{443, 443}, {80, 80}}), + "non-contiguous ranges stay separate and sorted") +} + +// portRangeInSpecs matches a range against a list by normalized value; it backs +// CSF's port-removal path. +func TestPortRangeInSpecs(t *testing.T) { + specs := []PortRange{{80, 80}, {1000, 2000}} + require.True(t, portRangeInSpecs(PortRange{Start: 80}, specs), "a bare single port matches its normalized form") + require.True(t, portRangeInSpecs(PortRange{Start: 1000, End: 2000}, specs), "a range matches exactly") + require.True(t, portRangeInSpecs(PortRange{Start: 80, End: 0}, specs), "a zero End normalizes to a single port") + require.False(t, portRangeInSpecs(PortRange{Start: 443}, specs), "an absent port does not match") + require.False(t, portRangeInSpecs(PortRange{Start: 1000, End: 1999}, specs), "a different range does not match") +} + +func TestParseRateUnit(t *testing.T) { + cases := map[string]RateUnit{ + "s": PerSecond, "sec": PerSecond, "second": PerSecond, + "m": PerMinute, "min": PerMinute, "minute": PerMinute, + "h": PerHour, "hour": PerHour, + "d": PerDay, "day": PerDay, + } + for in, want := range cases { + got, err := ParseRateUnit(in) + require.NoError(t, err, "unit %q", in) + require.Equal(t, want, got, "unit %q", in) + } + _, err := ParseRateUnit("fortnight") + require.Error(t, err) +} + +func TestRateLimitString(t *testing.T) { + require.Equal(t, "10/minute", (&RateLimit{Rate: 10, Unit: PerMinute}).String()) + require.Equal(t, "5/second", (&RateLimit{Rate: 5, Unit: PerSecond}).String()) +} + +func TestNATRuleEqualAndMerge(t *testing.T) { + a := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} + b := &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} + require.True(t, a.EqualBase(b), "same match, different family") + require.False(t, a.Equal(b), "Equal must honor family") + + merged := mergeNATFamilies([]*NATRule{a, b}) + require.Len(t, merged, 1, "an ipv4/ipv6 pair should merge") + require.Equal(t, FamilyAny, merged[0].Family) +} + +func TestRuleLogLimitIdentity(t *testing.T) { + base := &Rule{Port: 22, Proto: TCP, Action: Accept} + logged := &Rule{Port: 22, Proto: TCP, Action: Accept, Log: true} + require.False(t, base.EqualBase(logged, true), "logging is part of rule identity") + + limited := &Rule{Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}} + require.False(t, base.EqualBase(limited, true), "a rate limit is part of rule identity") + same := &Rule{Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}} + require.True(t, limited.EqualBase(same, true)) + + srcPort := &Rule{Port: 22, Proto: TCP, Action: Accept, SourcePort: 1234} + require.False(t, base.EqualBase(srcPort, true), "source port is part of rule identity") +} + +// The added transport/tunnel protocols render and parse consistently, and only +// the port-carrying ones (tcp/udp/sctp) accept a port. +func TestProtocolExtras(t *testing.T) { + for _, p := range []Protocol{SCTP, GRE, ESP, AH} { + require.Equal(t, p, GetProtocol(p.String()), "round-trip %s", p) + } + require.Equal(t, ESP, GetProtocol("ipsec-esp")) + require.Equal(t, AH, GetProtocol("ipsec-ah")) + + require.True(t, SCTP.HasPorts(), "sctp carries ports") + require.False(t, GRE.HasPorts(), "gre is portless") + require.False(t, ESP.HasPorts()) + require.False(t, AH.HasPorts()) + + // A port is valid with SCTP but not with a portless protocol. + require.False(t, (&Rule{Proto: SCTP, Port: 9000}).PortNeedsConcreteProtocol()) + require.True(t, (&Rule{Proto: GRE, Port: 9000}).PortNeedsConcreteProtocol()) +} + +// A comment is informational metadata: two rules that differ only in their +// comment are equal, and removal need not name the comment. +func TestCommentNotPartOfIdentity(t *testing.T) { + a := &Rule{Port: 22, Proto: TCP, Action: Accept, Comment: "ssh"} + b := &Rule{Port: 22, Proto: TCP, Action: Accept, Comment: "something else"} + c := &Rule{Port: 22, Proto: TCP, Action: Accept} + require.True(t, a.EqualBase(b, true)) + require.True(t, a.Equal(b, true)) + require.True(t, a.EqualBase(c, true)) +} + +// Direction/SetType string rendering is stable for the rule encoders. +func TestDirectionAndSetType(t *testing.T) { + require.Equal(t, "input", DirInput.String()) + require.Equal(t, "output", DirOutput.String()) + require.Equal(t, "forward", DirForward.String()) + require.Equal(t, "hash:ip", SetHashIP.String()) + require.Equal(t, "hash:net", SetHashNet.String()) +} + +// GetRules merges an IPv4/IPv6 pair into one FamilyAny rule and numbers the result, +// so a rule's Number counts logical (merged) rules. But nft chain edits act on the +// physical rows. mergedFamilyAnchors must map each merged rule back to its physical +// anchor so InsertRule/MoveRule place rules where the caller's Number says. +func TestMergedFamilyAnchorsMatchNumbering(t *testing.T) { + mk := func(fam Family, port uint16) *Rule { + return &Rule{Family: fam, Proto: TCP, Port: port, Action: Accept} + } + // Physical input chain: [R4(22), R6(22), C(80)] — R4/R6 are a v4/v6 pair. + phys := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(FamilyAny, 80)} + anchors := mergedFamilyAnchors(phys) + require.Equal(t, []int{0, 2}, anchors, + "the merged pair anchors at physical index 0; C stays at physical index 2") + + // Cross-check against mergeFamilies + numberByDirection on an independent copy: + // each merged rule's Number must map through the anchors to its physical anchor. + cp := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(FamilyAny, 80)} + merged := mergeFamilies(cp) + numberByDirection(merged) + require.Len(t, merged, 2) + require.Equal(t, 1, merged[0].Number) + require.Equal(t, 0, anchors[merged[0].Number-1], "merged pair (Number 1) -> physical index 0") + require.Equal(t, 2, merged[1].Number) + require.Equal(t, 2, anchors[merged[1].Number-1], "C (Number 2) -> physical index 2") +} + +// A twin that is not adjacent to its anchor (a non-matching rule sits between the +// v4 and v6 rows) must still be absorbed, and the anchor order preserved. +func TestMergedFamilyAnchorsNonAdjacentTwin(t *testing.T) { + phys := []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, + {Proto: TCP, Port: 80, Action: Accept}, + {Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, + } + anchors := mergedFamilyAnchors(phys) + require.Equal(t, []int{0, 1}, anchors, + "R4 anchors the pair at index 0; the port-80 rule stays at index 1; R6 is absorbed") +} + +// The NAT anchor mapping mirrors the filter one. +func TestMergedNATFamilyAnchors(t *testing.T) { + phys := []*NATRule{ + {Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}, + {Kind: DNAT, Family: IPv6, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}, + {Kind: DNAT, Proto: TCP, Port: 443, ToAddress: "10.0.0.6", ToPort: 8443}, + } + anchors := mergedNATFamilyAnchors(phys) + require.Equal(t, []int{0, 2}, anchors) + require.Equal(t, 2, mergedInsertIndex(anchors, len(phys), 2), + "NAT insert before merged position 2 must target physical index 2") +} + +// recordManager is a minimal Manager that records mutations and does NOT +// implement RuleBatcher, exercising the fallback loop in AddRules and the diff +// engine in Sync. +type recordManager struct { + rules []*Rule + added int + removed int +} + +func (m *recordManager) Type() string { return "record" } +func (m *recordManager) Capabilities() Capabilities { + return Capabilities{Output: true} +} +func (m *recordManager) GetZone(ctx context.Context, iface string) (string, error) { return "", nil } +func (m *recordManager) GetRules(ctx context.Context, zone string) ([]*Rule, error) { + return m.rules, nil +} +func (m *recordManager) AddRule(ctx context.Context, zone string, r *Rule) error { + stored := *r + m.rules = append(m.rules, &stored) + m.added++ + return nil +} +func (m *recordManager) InsertRule(ctx context.Context, zone string, pos int, r *Rule) error { + return unsupportedOrdering(m.Type()) +} +func (m *recordManager) MoveRule(ctx context.Context, zone string, r *Rule, pos int) error { + return unsupportedOrdering(m.Type()) +} +func (m *recordManager) RemoveRule(ctx context.Context, zone string, r *Rule) error { + for i, e := range m.rules { + if e.Equal(r, true) { + m.rules = append(m.rules[:i], m.rules[i+1:]...) + m.removed++ + return nil + } + } + return nil +} +func (m *recordManager) GetNATRules(ctx context.Context, zone string) ([]*NATRule, error) { + return nil, unsupportedNAT(m.Type()) +} +func (m *recordManager) AddNATRule(ctx context.Context, zone string, r *NATRule) error { + return unsupportedNAT(m.Type()) +} +func (m *recordManager) InsertNATRule(ctx context.Context, zone string, position int, r *NATRule) error { + return unsupportedNAT(m.Type()) +} +func (m *recordManager) RemoveNATRule(ctx context.Context, zone string, r *NATRule) error { + return unsupportedNAT(m.Type()) +} +func (m *recordManager) Backup(ctx context.Context, zone string) (*Backup, error) { + return nil, nil +} +func (m *recordManager) Restore(ctx context.Context, zone string, b *Backup) error { + return nil +} +func (m *recordManager) GetDefaultPolicy(ctx context.Context, zone string) (*DefaultPolicy, error) { + return nil, unsupportedPolicy(m.Type()) +} +func (m *recordManager) SetDefaultPolicy(ctx context.Context, zone string, p *DefaultPolicy) error { + return unsupportedPolicy(m.Type()) +} +func (m *recordManager) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + return nil, unsupportedSet(m.Type()) +} +func (m *recordManager) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) { + return nil, unsupportedSet(m.Type()) +} +func (m *recordManager) AddAddressSet(ctx context.Context, s *AddressSet) error { + return unsupportedSet(m.Type()) +} +func (m *recordManager) RemoveAddressSet(ctx context.Context, name string) error { + return unsupportedSet(m.Type()) +} +func (m *recordManager) AddAddressSetEntry(ctx context.Context, name, entry string) error { + return unsupportedSet(m.Type()) +} +func (m *recordManager) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + return unsupportedSet(m.Type()) +} +func (m *recordManager) Reload(ctx context.Context) error { return nil } +func (m *recordManager) Close(ctx context.Context) error { return nil } + +// A backend that does not implement RuleBatcher falls back to per-rule AddRule, +// and Sync computes the correct add/remove diff against it. +func TestAddRulesAndSyncFallback(t *testing.T) { + ctx := context.Background() + m := &recordManager{} + + // Not a RuleBatcher: AddRules must loop AddRule. + _, isBatcher := interface{}(m).(RuleBatcher) + require.False(t, isBatcher) + + require.NoError(t, AddRules(ctx, m, "", []*Rule{ + {Port: 22, Proto: TCP, Action: Accept}, + {Port: 80, Proto: TCP, Action: Accept}, + })) + require.Equal(t, 2, m.added) + + // Sync keeps 22, removes 80, adds 443. + added, removed, err := Sync(ctx, m, "", []*Rule{ + {Port: 22, Proto: TCP, Action: Accept}, + {Port: 443, Proto: TCP, Action: Accept}, + }) + require.NoError(t, err) + require.Equal(t, 1, added) + require.Equal(t, 1, removed) + require.Len(t, m.rules, 2) +} diff --git a/firewalld_linux.go b/firewalld_linux.go new file mode 100644 index 0000000..97861b0 --- /dev/null +++ b/firewalld_linux.go @@ -0,0 +1,1611 @@ +package firewall + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + + firewalld "github.com/grmrgecko/go-firewalld" +) + +// FirewallDType is the backend identifier reported by FirewallD.Type. +const FirewallDType = "firewalld" + +// 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 +} + +// 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 +} + +// 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 +} + +// 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 +} + +// 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, so we fall through to + // the default zone below. + zoneName, err = f.Conn.Permanent().ZoneOfInterface(ctx, iface) + if err == nil && zoneName != "" { + return zoneName, nil + } + + // If we did not find a zone for the specified interface, use the default + // zone if it exists. + defaultZone, derr := f.Conn.DefaultZone(ctx) + if derr == nil && defaultZone != "" { + return defaultZone, nil + } + + // If we were unable to find the zone or a default zone, error out. + return "", fmt.Errorf("unable to find zone") +} + +// 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) +} + +// 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). + if strings.HasPrefix(tokens[i], "address=") { + address := trimQuotes(strings.TrimPrefix(tokens[i], "address=")) + if not { + r.Destination = "!" + address + } else { + r.Destination = address + } + r.Direction = DirOutput + } else if strings.HasPrefix(tokens[i], "ipset=") { + ipset := trimQuotes(strings.TrimPrefix(tokens[i], "ipset=")) + if not { + r.Destination = "!" + ipset + } else { + r.Destination = ipset + } + r.Direction = DirOutput + } 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++ + } + } 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. + i++ + if i >= len(tokens) || !strings.HasPrefix(tokens[i], "name=") { + return nil, fmt.Errorf("the icmp-type element has no defined name") + } + 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 +} + +// 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() +} + +// 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, +} + +// 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 +} + +// 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 +} + +// 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 +} + +// 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 port element takes a single port or dash range, not a list, and + // has no conntrack-state or per-rule interface match (interfaces bind to + // zones instead). + 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) + } + if len(r.PortSpecs()) > 1 { + return "", fmt.Errorf("firewalld does not support a port list in a rich rule: %w", ErrUnsupported) + } + // A rich rule source-port element takes a single port or dash range, not a + // list, and 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.SourcePortSpecs()) > 1 { + return "", fmt.Errorf("firewalld does not support a source-port list in a rich rule: %w", ErrUnsupportedSourcePort) + } + 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 we try to render 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") + } + } + + // 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 +} + +// 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) + } + + // Collapse an IPv4/IPv6 pair of otherwise-identical rules into a single + // FamilyAny rule, as every other backend's GetRules does, so a rule added + // family-agnostically reads back the same way. + rules = mergeFamilies(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 +} + +// 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 +} + +// 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 { + // 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. + r = dirAnyInputFallback(r, f.Capabilities().Output) + + // 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) + } + + // 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)) +} + +// 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 +} + +// 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() +} + +// RemoveRule removes a filter rule from a zone, mirroring how AddRule stored it. +func (f *FirewallD) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + // A DirAny rule degrades to its input half on firewalld (no output concept), + // mirroring AddRule so a both-directions rule is found and removed as stored. + r = dirAnyInputFallback(r, f.Capabilities().Output) + + // 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, but NOT_ENABLED (the + // element is absent as a zone entry) falls through to the rich-rule path in + // case the rule was stored in that form out of band. + if f.zoneEntryEligible(r) { + // A single destination port (or contiguous range) maps to a zone port entry. + // Mirror AddRule: key on HasPorts (so a single port in the Ports slice + // matches) and require a concrete port protocol. + if r.HasPorts() && r.Proto.HasPorts() && r.Source == "" && !r.HasSourcePorts() { + port := firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()} + if err := zone.RemovePort(ctx, port); !errors.Is(err, firewalld.ErrNotEnabled) { + return err + } + } else if r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts() && r.Source == "" { + // A single source port maps to a zone source-port entry. + sp := firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()} + if err := zone.RemoveSourcePort(ctx, sp); !errors.Is(err, firewalld.ErrNotEnabled) { + return err + } + } else if f.sourceZoneShape(r) { + // 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 falls through to the + // rich-rule path below. + // sourceZoneShape mirrors AddRule's ProtocolAny guard: a source+protocol + // rule is a rich rule, so it must be removed as one rather than clearing + // the bare zone source. + if f.isZoneSource(r.Source) { + if err := zone.RemoveSource(ctx, r.Source); !errors.Is(err, firewalld.ErrNotEnabled) { + return err + } + } + } else if r.Proto != ProtocolAny && r.Source == "" && !r.HasPorts() && !r.HasSourcePorts() { + // A bare protocol allow (no port or address) maps to a zone protocol + // entry (firewall-cmd --add-protocol), which GetRules now surfaces. + // Mirror the port/source shortcuts so such a foreign entry is removable; + // a NOT_ENABLED result falls through to the rich-rule path for a rule the + // library stored as a rich-rule protocol match instead. + if err := zone.RemoveProtocol(ctx, r.Proto.String()); !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 == "" { + var removeErr error + matched := true + switch { + case r.HasPorts() && r.Proto.HasPorts() && !r.HasSourcePorts(): + removeErr = zone.RemovePort(ctx, firewalld.Port{Port: r.PortSpecs()[0].String(), Protocol: r.Proto.String()}) + case r.HasSourcePorts() && r.Proto.HasPorts() && !r.HasPorts(): + removeErr = zone.RemoveSourcePort(ctx, firewalld.Port{Port: r.SourcePortSpecs()[0].String(), Protocol: r.Proto.String()}) + case r.Proto != ProtocolAny && !r.HasPorts() && !r.HasSourcePorts(): + removeErr = zone.RemoveProtocol(ctx, r.Proto.String()) + default: + matched = false + } + if 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); !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: GetRules merges an + // IPv4/IPv6 rich-rule twin (what a concrete-family bare-port accept becomes) + // into one FamilyAny rule, so removing that read-back rule must clear both + // underlying rich rules, while a concrete-family target still removes only its + // own family. + 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)) +} + +// 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 — +// or a port list — cannot be expressed through it and is rejected. (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") + } + if !r.HasPorts() { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding requires a matched port") + } + specs := r.PortSpecs() + if len(specs) > 1 { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not support a port list in a port forward") + } + if r.Interface != "" { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld does not bind a port forward to an interface") + } + if r.Source != "" || r.Destination != "" { + return firewalld.ForwardPort{}, fmt.Errorf("firewalld port forwarding does not support source or destination matching") + } + 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 +} + +// 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 + } + + // Get the zone settings so we can read 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. + for _, fp := range settings.ForwardPorts { + pr, perr := ParsePortRange(fp.Port) + if perr != nil { + continue + } + rule := &NATRule{Proto: GetProtocol(fp.Protocol)} + 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 { + continue + } + rule.ToPort = uint16(tp) + } + rule.ToAddress = fp.ToAddr + if fp.ToAddr != "" { + rule.Kind = DNAT + } else { + rule.Kind = Redirect + } + 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 +} + +// 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: + 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") +} + +// 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 { + // 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: + 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") +} + +// 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()) +} + +// 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()) +} + +// 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()) +} + +// 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 all ports, sources, and rich rules that we can decode as managed. + for _, port := range settings.Ports { + if err := zone.RemovePort(ctx, port); err != nil { + return err + } + } + for _, sp := range settings.SourcePorts { + 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 err := zone.RemoveProtocol(ctx, proto); err != nil { + return err + } + } + for _, richRule := range settings.RichRules { + if err := zone.RemoveRichRule(ctx, richRule); err != nil { + return err + } + } + + // Remove NAT rules. + for _, fp := range settings.ForwardPorts { + 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() +} + +// Capabilities reports which optional features this backend supports. +func (f *FirewallD) Capabilities() Capabilities { + return Capabilities{ + Output: false, + Zones: true, + Priority: true, + ICMPv6: true, + PortList: false, + ConnState: false, + InterfaceMatch: false, + Logging: true, + RateLimit: true, + ConnLimit: false, + NAT: true, + RuleOrdering: false, + DefaultPolicy: true, + RuleCounters: false, + AddressSets: true, + } +} + +// 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) ---------------------------------------- + +// 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" +} + +// 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 reads a single firewalld ipset, or nil if it does not exist. +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, Type: SetHashIP} + if settings.Type == "hash:net" { + set.Type = SetHashNet + } + switch settings.Options["family"] { + case "inet6": + set.Family = IPv6 + case "inet", "": + set.Family = IPv4 + } + return set, 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 +} + +// 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 +} diff --git a/firewalld_linux_test.go b/firewalld_linux_test.go new file mode 100644 index 0000000..6549f26 --- /dev/null +++ b/firewalld_linux_test.go @@ -0,0 +1,576 @@ +package firewall + +import ( + "errors" + "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. + rule, 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.NoError(t, err) + + // Re-encode the rule which should result in expected rich rule. The log and + // rate limit now 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") + + // 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="67.227.233.116" port port="4789" protocol="tcp" accept`, + `rule priority="10" family="ipv4" destination address="67.227.233.116" 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") + require.False(t, fw.Capabilities().Forward, "firewalld does not advertise forward support") +} + +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. + unsupported := []*Rule{ + {Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}, + {InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, + {Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, 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 (verified against the real firewalld.core.rich parser), 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.True(t, parsed.IsOutput(), "a destination match is an output rule") + + // 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) + } + + // firewalld rich rules carry a single port element, and a source-port takes a + // single port or range — so these cannot be expressed. + 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 list is not expressible. + {Proto: TCP, SourcePorts: []PortRange{{Start: 80}, {Start: 443}}, 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 two source-port-specific rejections report the source-port sentinel. + _, err := fw.MarshalRichRule(&Rule{Proto: TCP, Port: 80, SourcePort: 1024, Action: Accept}) + require.ErrorIs(t, err, ErrUnsupportedSourcePort) + _, err = fw.MarshalRichRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}) + require.ErrorIs(t, err, ErrUnsupportedSourcePort) +} + +// A concrete-family bare-port accept is stored as a rich rule (zoneEntryEligible +// requires FamilyAny), so a v4/v6 pair becomes two rich rules that GetRules merges +// into one FamilyAny rule. RemoveRule must locate that merged rule against the +// stored rich rules with EqualForRemoval — the family-strict Equal it used +// before matched neither, so the port stayed open. Regression for the firewalld +// merged-family remove no-op surfaced by the integration suite. +func TestFirewallDMergedRichRuleRemovable(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) + + merged := mergeFamiliesCopy([]*Rule{v4, v6}) + require.Len(t, merged, 1, "the v4/v6 rich-rule twin must collapse into one rule") + m := merged[0] + require.Equal(t, FamilyAny, m.impliedFamily()) + + // The old family-strict matcher found neither stored rich rule. + require.False(t, m.Equal(v4, false)) + require.False(t, m.Equal(v6, false)) + + // The new matcher (EqualForRemoval) finds both, so RemoveRule clears both rich + // rules for a merged read-back rule. + require.True(t, v4.EqualForRemoval(m, false)) + require.True(t, v6.EqualForRemoval(m, 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. Regression for the +// raw-burst guard that ignored 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. Live testing +// against firewalld 2.4.3 confirmed it accepts a familyless rich rule carrying +// `protocol value="ipv6-icmp"` just like any other protocol (its rich.py check() +// 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 without needing a family qualifier. +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") +} + +// A firewalld rich rule limit is a bare rate with no burst allowance. A non-zero +// Burst must be rejected rather than silently dropped: a dropped burst reads back +// as 0, so Rule.Equal never matches the desired rule and Sync churns forever. A +// zero Burst (backend default) round-trips. +func TestFirewalldRateBurstRejected(t *testing.T) { + fw := new(FirewallD) + + r0 := &Rule{Action: Accept, Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}} + rr, err := fw.MarshalRichRule(r0) + require.NoError(t, err) + back, err := fw.UnmarshalRichRule(rr) + require.NoError(t, err) + require.True(t, r0.Equal(back, false), "burst-0 rate limit must round-trip; got %+v", back.RateLimit) + + rb := &Rule{Action: Accept, Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 20}} + _, err = fw.MarshalRichRule(rb) + require.Error(t, err, "a non-zero burst must be rejected, not silently dropped") + require.True(t, errors.Is(err, ErrUnsupportedRateLimit), "error should wrap ErrUnsupportedRateLimit, got: %v", err) +} + +// 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 list is not, below). + 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"}}, + {"port list", &NATRule{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, ToAddress: "10.0.0.5", ToPort: 8080}}, + {"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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9c24ed7 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/grmrgecko/go-firewall + +go 1.26.4 + +replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0 + +require ( + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be + github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf + 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 + go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf +) + +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/pmezard/go-difflib v1.0.0 // indirect + github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e // indirect + golang.org/x/sys v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9352c10 --- /dev/null +++ b/go.sum @@ -0,0 +1,120 @@ +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/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/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/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= +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/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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.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..aced25c --- /dev/null +++ b/hooks_linux.go @@ -0,0 +1,393 @@ +package firewall + +import ( + "fmt" + "os" + "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. +// +// The command lines live in the hook file itself (hookPath) rather than in a +// separate library-owned script: edit touches only the exact command lines it +// manages, leaving any user-authored hook content in place, and getRules parses +// every iptables/ip6tables line the hook carries. Reading foreign, user-authored +// hook rules is intended — the library manages the actual firewall state, and +// HasPrefix (derived from the rule's comment tag) is the only signal of what it +// created. +type hookScript struct { + // rulePrefix tags each injected rule with an iptables comment 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 the hook file must carry to be executed (0700 for CSF, + // 0750 for APF). + hookPerm os.FileMode +} + +// 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, or a transport +// protocol their native config does not model (SCTP and the portless IP protocols +// GRE, ESP and AH). +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) +} + +// 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 + } + 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) +} + +// hostNeedsHook reports whether an address-bearing rule with no port must be +// injected through the raw-iptables hook because a csf/apf trust file has no form +// for it: a source+destination pair (both store a single address) or a host +// pinned to a concrete tcp/udp protocol (the plain line is all-protocol and the +// advanced rule requires a port). Both are expressed natively as an iptables +// rule. A single-address all-protocol host is not covered here — a one-way one is +// routed by bareHostOneWay, a bidirectional one is a native plain line — and ICMP +// keeps its own handling (checkICMP/apfCheckICMP), so it is excluded. Shared by +// CSF and APF. +func hostNeedsHook(r *Rule) bool { + if r.Proto.IsICMP() || r.HasPorts() || r.HasSourcePorts() { + return false + } + // A source+destination pair has no single-address advanced/plain form. + if r.Source != "" && r.Destination != "" { + return true + } + // A single-address host pinned to tcp/udp has no portless form. + if r.Source != "" || r.Destination != "" { + return r.Proto == TCP || r.Proto == UDP + } + return false +} + +// bareProtoNeedsHook reports whether a rule is a bare protocol match — a non-ICMP +// transport with no address and no port — that CSF/APF cannot express in their +// native config (the trust files key on an address and the conf lists key on a +// port or icmp type) but iptables applies directly (`-p tcp -j ACCEPT`, or a bare +// `-j ACCEPT`). Such a rule is injected through the raw-iptables hook rather than +// rejected. ICMP/ICMPv6 keep their own native/hook handling (checkICMP/apfCheckICMP, +// nativeICMPv6, ruleNeedsHook), so they are excluded here. Shared by CSF and APF. +func bareProtoNeedsHook(r *Rule) bool { + return r.Source == "" && r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && !r.Proto.IsICMP() +} + +// 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 +} + +// hookRuleProtos lists the transport protocols a rule is written for in the hook. +// A ProtocolAny rule that carries a port has no single iptables form — a --dport/ +// --sport match requires a concrete -p tcp/udp — so it fans out into a tcp line and +// a udp line, mirroring the tcp+udp fan-out csf/apf write for a ProtocolAny port +// rule in their native config. A portless ProtocolAny rule is a valid protocol- +// agnostic match (a bare `-j ACCEPT`), so it keeps its own protocol and is not fanned. +func hookRuleProtos(r *Rule) []Protocol { + if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) { + return []Protocol{TCP, UDP} + } + return []Protocol{r.Proto} +} + +// hookRuleFamilies lists the address families a rule is written for: a rule +// pinned to a family (by address or an ICMP protocol) touches only that command, +// a family-agnostic rule (e.g. a bare state match) is written for both v4 and v6. +func hookRuleFamilies(r *Rule) []Family { + switch r.impliedFamily() { + case IPv4: + return []Family{IPv4} + case IPv6: + return []Family{IPv6} + default: + return []Family{IPv4, IPv6} + } +} + +// hookCommand returns the iptables command name for a family. +func hookCommand(fam Family) string { + if fam == IPv6 { + return "ip6tables" + } + return "iptables" +} + +// shellSafeToken quotes a token so /bin/sh passes it through verbatim. The +// iptables marshaller quotes free-text fields (a comment, a log prefix) with +// strconv.Quote — Go double-quoting, which is right for an iptables-restore file +// but NOT for the hook script, which /bin/sh sources: inside double quotes the +// shell still expands $var, $(...) and backticks. 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, "'", `'\''`) + "'" +} + +// rulesToLines encodes a rule as the raw command line(s) to inject: one iptables +// (or ip6tables) command per underlying iptables line and per family. A logged +// rule yields a LOG line followed by its action line, as with the iptables +// backend. 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) rulesToLines(r *Rule) ([]string, error) { + var out []string + for _, proto := range hookRuleProtos(r) { + for _, fam := range hookRuleFamilies(r) { + rc := *r + rc.Proto = proto + rc.Family = fam + ipt := &IPTables{rulePrefix: h.rulePrefix} + base, err := ipt.marshalRuleLines(&rc) + if err != nil { + return nil, err + } + cmd := hookCommand(fam) + 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 +} + +// ruleMatchesAny reports whether e — a rule parsed from a hook line — is the same +// underlying rule as any of targets, honoring direction (the hook emits explicit +// -A INPUT/-A OUTPUT lines) but ignoring the comment, which is not part of rule +// identity. It backs comment-agnostic hook removal. +func ruleMatchesAny(e *Rule, targets []*Rule) bool { + for _, t := range targets { + if e.Equal(t, true) { + return true + } + } + return false +} + +// 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. +func (h *hookScript) parseLine(line string) (*Rule, bool) { + line = strings.TrimSpace(line) + var fam Family + var rest string + switch { + case strings.HasPrefix(line, "iptables "): + fam, rest = IPv4, strings.TrimPrefix(line, "iptables ") + case strings.HasPrefix(line, "ip6tables "): + fam, rest = IPv6, strings.TrimPrefix(line, "ip6tables ") + default: + return nil, false + } + r, err := unmarshalIPTablesRule(rest, fam) + if err != nil { + return nil, false + } + // The iptables parser captures the prefixed comment; strip the prefix (a tag) + // so only the user-facing comment surfaces, and record whether the injected + // rule carried the prefix so HasPrefix reflects it just like the iptables + // backend. + text, hasPrefix := prefixedComment(h.rulePrefix, r.Comment) + r.Comment = text + r.HasPrefix = hasPrefix + return r, true +} + +// commandLines returns the iptables/ip6tables command lines currently in the +// hook (a missing hook contributes none). Every such line is returned, including +// any a user authored by hand, so the library reconciles the hook's real state. +func (h *hookScript) commandLines() ([]string, error) { + lines, _, err := h.readHookLines() + if err != nil { + return nil, err + } + var cmds []string + for _, line := range lines { + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "iptables ") || strings.HasPrefix(t, "ip6tables ") { + cmds = append(cmds, t) + } + } + return cmds, nil +} + +// getRules parses the hook into logical rules, coalescing each LOG line with the +// action line that follows it. Family merging is left to the caller, which +// unions these with the backend's native rules. +func (h *hookScript) getRules() ([]*Rule, error) { + cmds, err := h.commandLines() + if err != nil { + return nil, err + } + var rules []*Rule + for _, line := range cmds { + if r, ok := h.parseLine(line); ok { + rules = append(rules, r) + } + } + return coalesceLoggedRules(rules), nil +} + +// readHookLines returns the hook's lines and whether the hook file exists. A +// single trailing newline is trimmed so a rewrite does not accrue a blank line; +// a missing hook yields no lines. +func (h *hookScript) readHookLines() ([]string, bool, error) { + data, err := os.ReadFile(h.hookPath) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + content := strings.TrimSuffix(string(data), "\n") + if content == "" { + return nil, true, nil + } + return strings.Split(content, "\n"), true, nil +} + +// writeHook atomically replaces the hook with lines, giving a freshly created +// hook a shebang, and keeps it executable so the firewall can source it. +func (h *hookScript) writeHook(lines []string, existed bool) error { + var b strings.Builder + if !existed { + b.WriteString("#!/bin/sh\n") + } + for _, l := range lines { + b.WriteString(l) + b.WriteByte('\n') + } + // Preserve an existing hook's mode and ownership; a freshly created hook gets + // the executable hookPerm so the firewall can source it. + if err := writeConfigFile(h.hookPath, []byte(b.String()), h.hookPerm); err != nil { + return fmt.Errorf("failed to move updated hook into place: %s", err) + } + return nil +} + +// edit adds or removes a rule's command line(s) directly in the hook. Adding +// matches on the exact deterministic line the marshaller emits, so the library's +// own tagged line is written once and re-adds are idempotent. Removal instead +// matches on the underlying rule: a line is dropped when the rule it encodes is +// the same as one r resolves to, ignoring the comment tag, so a copy of the rule a +// customer added under a different comment (or none) is cleared too — the comment +// is not part of rule identity. A logged rule's LOG and action lines are matched +// independently, exactly as they were written. It preserves every other hook +// line — user-authored shell and rules alike — and 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.rulesToLines(r) + if err != nil { + return false, err + } + + lines, existed, err := h.readHookLines() + if err != nil { + return false, err + } + + changed := false + if remove { + // Parse each desired line back into the rule it encodes; a hook line whose own + // rule matches one of these (comment ignored, see Rule.Equal) is dropped. A line + // that is not a rule the hook recognizes (foreign shell, a comment) never parses + // and is preserved. + var targets []*Rule + for _, l := range desired { + if tr, ok := h.parseLine(l); ok { + targets = append(targets, tr) + } + } + next := lines[:0:0] + for _, l := range lines { + if er, ok := h.parseLine(l); ok && ruleMatchesAny(er, targets) { + changed = true + continue + } + next = append(next, l) + } + if !changed { + return false, nil + } + lines = next + } else { + present := make(map[string]bool, len(lines)) + for _, l := range lines { + present[strings.TrimSpace(l)] = true + } + for _, l := range desired { + if !present[l] { + lines = append(lines, l) + present[l] = true + changed = true + } + } + if !changed { + return false, nil + } + } + + return true, h.writeHook(lines, existed) +} diff --git a/hooks_linux_test.go b/hooks_linux_test.go new file mode 100644 index 0000000..f625c60 --- /dev/null +++ b/hooks_linux_test.go @@ -0,0 +1,448 @@ +package firewall + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRuleNeedsHook(t *testing.T) { + // Features with no native CSF/APF config path route through the hook. + needs := []*Rule{ + {Proto: TCP, Port: 22, Action: Accept, State: StateNew}, + {Proto: TCP, Port: 80, Action: Accept, Log: true}, + {Proto: TCP, Port: 80, Action: Accept, InInterface: "eth0"}, + {Direction: DirOutput, Proto: TCP, Port: 80, Action: Accept, OutInterface: "eth0"}, + {Proto: TCP, Port: 25, Action: Drop, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}, + {Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, + // A forward rule has no native CSF/APF config path, so it routes through the + // raw-iptables hook (which emits an -A FORWARD rule). + {Direction: DirForward, Proto: TCP, Port: 8080, Action: Accept}, + } + for _, r := range needs { + require.True(t, ruleNeedsHook(r), "expected %+v to need the hook", *r) + } + // Natively expressible rules do not. + native := []*Rule{ + {Proto: TCP, Port: 22, Action: Accept}, + {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, + {Proto: TCP, Port: 22, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, + } + for _, r := range native { + require.False(t, ruleNeedsHook(r), "expected %+v to stay native", *r) + } +} + +// bareProtoNeedsHook routes a portless, addressless non-ICMP match to the hook — +// the shape CSF/APF cannot express natively but iptables applies directly — while +// leaving every rule that carries an address, a port, or an ICMP protocol on its +// own native/ICMP path. +func TestBareProtoNeedsHook(t *testing.T) { + // Bare protocol matches with no address and no port go to the hook. + needs := []*Rule{ + {Proto: TCP, Action: Accept}, + {Proto: UDP, Action: Drop}, + {Proto: ProtocolAny, Action: Accept}, + {Proto: TCP, Direction: DirOutput, Action: Accept}, + } + for _, r := range needs { + require.True(t, bareProtoNeedsHook(r), "expected %+v to route to the hook", *r) + } + // A port, an address, or an ICMP protocol keeps the rule off this path. + native := []*Rule{ + {Proto: TCP, Port: 22, Action: Accept}, + {Proto: TCP, SourcePort: 1234, Action: Accept}, + {Proto: TCP, Source: "1.2.3.4", Action: Accept}, + {Proto: ProtocolAny, Destination: "1.2.3.4", Action: Accept}, + {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, + {Proto: ICMPv6, Action: Accept}, + } + for _, r := range native { + require.False(t, bareProtoNeedsHook(r), "expected %+v to stay off the hook route", *r) + } +} + +// Two rules that are Equal (port-set order is not part of rule identity) must +// inject the same command line, 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 exact +// marshalled line, so the marshaller must render Equal port sets identically. +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 ProtocolAny rule that carries a port has no single iptables form — a --dport +// match requires a concrete -p tcp/udp — 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. Regression: csf's GetRules merges a same-port TCP_IN/UDP_IN pair back +// into one ProtocolAny rule, so a Backup captured it and Restore's hook-copy clear +// then failed to marshal it, breaking the whole restore. +func TestHookScriptProtocolAnyPortFansOut(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + } + + // Adding a ProtocolAny port rule injects a tcp line and a udp line. + any := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 20, Action: Accept} + changed, err := h.edit(any, false) + require.NoError(t, err, "a ProtocolAny 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 ProtocolAny 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 ProtocolAny 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 ProtocolAny port rule must not fail to marshal") + require.True(t, changed, "the ProtocolAny 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 ProtocolAny 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) + } +} + +// Hook removal matches on the underlying rule, not the exact command line: a copy +// of a rule a customer added under a different comment (or a differently spelled +// address) must still be removed, since the comment is not part of rule identity. +func TestHookScriptRemoveIgnoresComment(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + } + + // Plant a rule the way a customer would: same underlying match, a foreign comment, + // and an un-normalized address (no /32). A hookScript with a different prefix marks + // it as not ours. + foreign := &hookScript{rulePrefix: "acme", hookPath: h.hookPath, hookPerm: 0700} + planted := &Rule{Family: IPv4, Proto: TCP, Port: 4567, Source: "192.0.2.60", Action: Accept, Comment: "ticket-42"} + changed, err := foreign.edit(planted, false) + require.NoError(t, err) + require.True(t, changed) + + // Remove the same underlying rule with no comment and the normalized address. + changed, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 4567, Source: "192.0.2.60/32", Action: Accept}, true) + require.NoError(t, err) + require.True(t, changed, "a rule with the same match but a different comment must still be removed") + + got, err := h.getRules() + require.NoError(t, err) + require.Empty(t, got, "the customer's differently-commented copy must be gone") +} + +func TestHookScriptRoundTrip(t *testing.T) { + dir := t.TempDir() + h := &hookScript{ + rulePrefix: "go_firewall", + hookPath: filepath.Join(dir, "csfpre.sh"), + hookPerm: 0700, + } + + // A family-agnostic rule is injected for both v4 and v6. + lines, err := h.rulesToLines(&Rule{Proto: TCP, Port: 8080, Action: Accept, State: StateNew}) + 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.rulesToLines(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, Log: true, LogPrefix: "drop $x"}) + 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.rulesToLines(orig) + 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) + } +} + +// bareHostOneWay classifies a one-way bare-address host rule — a single address, +// no ports, any-protocol, a concrete direction — which csf/apf must route to the +// hook because a plain line is bidirectional and an advanced rule needs a port. A +// DirAny bare host (the bidirectional plain line) and any ported or protocol-pinned +// rule are excluded. +func TestBareHostOneWay(t *testing.T) { + yes := []*Rule{ + {Direction: DirInput, Source: "1.2.3.4", Action: Accept}, + {Direction: DirOutput, Destination: "1.2.3.4", Action: Accept}, + {Direction: DirInput, Source: "10.0.0.0/8", Action: Drop}, + } + for _, r := range yes { + require.Truef(t, bareHostOneWay(r), "expected one-way bare host: %+v", r) + } + no := []*Rule{ + {Direction: DirAny, Source: "1.2.3.4", Action: Accept}, // bidirectional plain line + {Direction: DirForward, Source: "1.2.3.4", Action: Accept}, // forward is hooked separately + {Direction: DirInput, Source: "1.2.3.4", Proto: TCP, Port: 22, Action: Accept}, // has a port (advanced) + {Direction: DirInput, Source: "1.2.3.4", Proto: TCP, Action: Accept}, // pins a protocol + {Direction: DirInput, Action: Accept}, // no address + {Direction: DirInput, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}, // both addresses + } + for _, r := range no { + require.Falsef(t, bareHostOneWay(r), "expected NOT one-way bare host: %+v", r) + } +} + +// hostNeedsHook selects the portless address rules a csf/apf trust file cannot +// express — a concrete tcp/udp host or a source+destination pair — so AddRule +// diverts them to the raw-iptables hook instead of rejecting them. An all- +// protocol single-address host (a native plain line or a bareHostOneWay hook +// rule), a port-bearing rule, an address-less rule, and ICMP stay off this path. +func TestHostNeedsHook(t *testing.T) { + cases := []struct { + name string + rule *Rule + want bool + }{ + {"tcp host no port", &Rule{Proto: TCP, Source: "1.2.3.4"}, true}, + {"udp host no port outbound", &Rule{Proto: UDP, Destination: "1.2.3.4", Direction: DirOutput}, true}, + {"source and destination", &Rule{Source: "1.2.3.4", Destination: "5.6.7.8"}, true}, + {"source and destination with proto", &Rule{Proto: TCP, Source: "1.2.3.4", Destination: "5.6.7.8"}, true}, + {"all-protocol single host", &Rule{Source: "1.2.3.4"}, false}, + {"tcp host with port", &Rule{Proto: TCP, Port: 22, Source: "1.2.3.4"}, false}, + {"tcp host with source port", &Rule{Proto: TCP, SourcePort: 22, Source: "1.2.3.4"}, false}, + {"address-less port rule", &Rule{Proto: TCP, Port: 22}, false}, + {"icmp host", &Rule{Proto: ICMP, Source: "1.2.3.4"}, false}, + {"icmpv6 source and destination", &Rule{Proto: ICMPv6, Source: "2001:db8::1", Destination: "2001:db8::2"}, false}, + } + for _, c := range cases { + require.Equal(t, c.want, hostNeedsHook(c.rule), c.name) + } +} 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..fd3e2ea --- /dev/null +++ b/integration_linux_test.go @@ -0,0 +1,26 @@ +//go:build integration + +package firewall + +import ( + "context" + "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()) +} diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..0d49683 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,1848 @@ +//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" + "os" + "os/exec" + "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) +} + +// 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"). + // Regression for ufw dropping the protocol and emitting 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("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("hosthooknoport", func(t *testing.T) { + // 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. apf routes all of these to the raw-iptables hook (hostNeedsHook / + // needsHook); csf routes the host shapes to the hook and expresses a + // multi-port list natively as a comma list. Either way each must round-trip. + // Gated to csf/apf; the chain backends express these natively (covered + // elsewhere). + switch mgr.Type() { + case CSFType, APFType: + default: + t.Skip("only csf/apf route these shapes to the hook") + } + // A concrete-protocol host with no port. + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Source: "192.0.2.40/32", Action: Accept}) + // A source+destination pair with no port. + roundTripRule(t, ctx, mgr, zone, &Rule{Source: "192.0.2.41/32", Destination: "192.0.2.42/32", Action: Accept}) + // 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. + roundTripVariants(t, ctx, mgr, zone, + &Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Reject}, + &Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Drop}, + ) + // A multi-port list as a host accept: apf hooks it, csf writes an advanced + // rule; no action ambiguity, so a direct round-trip covers both. + roundTripRule(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("negatedaddress", func(t *testing.T) { + // ufw's tuple grammar cannot negate an address, so a negated source is routed + // to the before.rules raw path (iptables `! -s`) rather than rejected, and + // must round-trip through it. Gated to ufw: this exercises that specific + // reroute; other backends negate (or reject) through their own paths. + if mgr.Type() != UFWType { + t.Skip("exercises ufw's negated-address reroute to before.rules") + } + 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("denyactionfromconfig", func(t *testing.T) { + // csf.deny / apf deny_hosts encode no action of their own: the tool applies + // its deny chain with an action taken from config — csf.conf DROP (stock + // default DROP), apf conf.apf ALL_STOP (stock default DROP). A deny whose + // action matches that is stored natively and must read back with it, not a + // fixed Reject — or a caller managing a Drop rule sees it churn on every Sync + // (read back as Reject, never equal to the desired Drop). A deny whose action + // differs has no native form but is expressible directly in iptables, so these + // backends inject it through their pre-hook rather than refusing it; it must + // round-trip with its exact action. Only the csf/apf address-list backends + // derive a deny's action from config this way. + switch mgr.Type() { + case "csf", "apf": + default: + t.Skip("backend does not derive a deny's action from config") + } + // The config-matching action (Drop on a stock host) is stored natively; the + // opposite action is injected through the hook. Both must read back exactly, so + // exercise both rather than assuming which one the host's config makes native. + for _, deny := range []*Rule{ + {Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.30/32", Action: Drop}, + {Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.31/32", Action: Reject}, + } { + require.NoError(t, mgr.AddRule(ctx, zone, deny), + "a deny must be storable natively or through the hook, never refused: %+v", 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 the config's 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("removeclearscustomerhookcopy", func(t *testing.T) { + // A rule shape csf/apf can express natively might also have been placed in the + // pre-hook by hand — a customer editing csfpre.sh / hook_pre.sh. RemoveRule must + // clear the hook copy too: routing such a rule only to the native path would + // remove it from the config while leaving it running in the hook. Only the + // csf/apf address-list backends carry a pre-hook, so plant the rule directly in + // theirs to stand in for the hand-added copy. + var plant func(*Rule) error + switch b := mgr.(type) { + case *APF: + plant = func(r *Rule) error { _, err := b.hook().edit(r, false); return err } + case *CSF: + plant = func(r *Rule) error { _, err := b.hook().edit(r, false); return err } + default: + t.Skip("backend has no pre-hook") + } + + // A host+port accept is a natively expressible shape, so RemoveRule routes it to + // the config; before the fix it never looked in the hook. + r := &Rule{Family: IPv4, Proto: TCP, Port: 4567, Source: "192.0.2.60/32", Action: Accept} + require.NoError(t, plant(r), "planting the customer hook copy must succeed") + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), + "the planted hook 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 hook 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("mergedfamilyremove", func(t *testing.T) { + // A v4 rule and its v6 twin differ only in Family, so GetRules collapses the + // pair into one FamilyAny rule. Removing that read-back rule must clear BOTH + // underlying rows. Regression for a family-strict remove that could not match + // a merged rule at all (a silent no-op that left the port open) or that + // removed only one of the two rows. + 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 merged FamilyAny + // rule, or one per family), then confirm none remain — removing the merged + // rule 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 mergedfamilyremove (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. + // Where its model cannot express a single-family rule of the shape (apf's + // dual-stack port lists, wf's address-less filters) it rejects the removal with + // ErrUnsupported instead. Regression for a concrete-family removal that dropped + // both families (nft/pf over-remove) or no-oped and left both (firewalld zone + // entries under-remove). + + // 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 (or the backend rejects the removal). + runSplit := func(t *testing.T, c splitCase) { + 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. A backend that cannot express a + // single-family rule of this shape rejects with ErrUnsupported, so skip. + if err := mgr.RemoveRule(ctx, zone, c.v4); errors.Is(err, ErrUnsupported) { + t.Skip("backend cannot express a single-family removal of this dual-family shape") + } else { + require.NoError(t, err) + } + 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) }) + } + ports := []uint16{before, dual, after} + order0 := managedPorts(t, ctx, mgr, zone, ports) + 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, managedPorts(t, ctx, mgr, zone, ports), + "the re-added surviving family must keep the dual rule's position") + }) + }) + + 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: + // Merged back into 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 added and read back must round-trip as exactly one DirAny rule, + // and a second add of the same rule must be an idempotent no-op (the merged + // DirAny rule dedups a re-add) rather than doubling the underlying 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() int { + n := 0 + for _, r := range rulesOf(t, ctx, mgr, zone) { + if r.Direction == DirAny && addrEqual(r.Source, "192.0.2.78") { + n++ + } + } + return n + } + require.Equal(t, 1, matches(), "a DirAny rule must read back as exactly one DirAny rule") + + // A redundant add must not create a second copy. + require.NoError(t, mgr.AddRule(ctx, zone, rule)) + require.Equal(t, 1, matches(), "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 collapse back to one DirAny 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, containsRule(rulesOf(t, ctx, mgr, zone), rule, caps.Output), + "a DirAny ported host rule must read back as one DirAny rule") + + require.NoError(t, mgr.RemoveRule(ctx, zone, rule)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, caps.Output), + "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 it needs a type + // (and forbids an address); csf builds ICMP on host-based rules so it needs + // an address AND a type (its advanced-rule format carries the type in the + // single port-flow field, so an address with no type is silently dropped by + // csf). 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.ICMPv6) + roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}) + }) + + t.Run("reloadv6raw", func(t *testing.T) { + requireCap(t, caps.ICMPv6) + // 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 address fallback for csf (which requires one + // on any ICMP rule). 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("apficmphook", func(t *testing.T) { + // apf carries ICMP as a zone-wide allowed-type list (IG_ICMP_TYPES), so an + // ICMP rule with an address or a non-accept action has no native form and + // routes to the hook (needsHook). The shared icmp/icmptype probes match + // apf on its native address-less accept form first, so exercise the hook path + // here. Gated to apf; type 13 (Timestamp) avoids echo-request collisions. + if mgr.Type() != APFType { + t.Skip("apf-specific ICMP hook routing") + } + roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.70/32", Action: Accept}) + roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Drop}) + }) + + t.Run("apfsinglefamilyport", func(t *testing.T) { + // apf's CPORTS lists are dual-stack, so a single-family bare port accept has + // no native form and is written per-family through the hook + // (dualStackPortNeedsHook) rather than rejected. It must round-trip on its + // own; the FamilyAny→single-family split is exercised by the destport split + // test. Gated to apf. + if mgr.Type() != APFType { + t.Skip("apf-specific single-family CPORTS-to-hook routing") + } + 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) { + requireCap(t, caps.PortList) + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, 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); apf requires an address on a source-port rule, so fall + // back to a form carrying a source address. + 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; apf a per-source one that rejects the excess; csf a per-source one + // that drops it; pf a per-source one only on an accept rule (single inbound + // tcp port, no address). iptables does the global form. 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("apfnativeconnlimit", func(t *testing.T) { + // apf writes a native connection limit — a single inbound tcp/udp port, no + // address, per-source, rejecting the excess — to conf.apf's CLIMIT lists + // rather than the hook (isConnLimitRule). apf now also routes every other + // connection-limit shape to the hook (needsHook), so the shared + // connlimit probe matches apf on its global hook form first; round-trip the + // native form here to keep the conf.apf CLIMIT path covered. Gated to apf. + if mgr.Type() != APFType { + t.Skip("apf-specific conf.apf CLIMIT path") + } + roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}}) + }) + + 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("natmergedfamilyremove", func(t *testing.T) { + requireCap(t, caps.NAT) + // A v4 masquerade and its v6 twin on the same interface differ only in Family, + // so GetNATRules collapses the pair into one FamilyAny rule (mergeNATFamilies). + // Removing that read-back rule must clear BOTH underlying rows. Regression for + // a NAT remove that stopped at the first match and left the IPv6 twin loaded + // (pf), mirroring the filter-side mergedfamilyremove 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 (no twin to merge)") + } + t.Cleanup(func() { + _ = mgr.RemoveNATRule(ctx, zone, v4) + _ = mgr.RemoveNATRule(ctx, zone, v6) + }) + + // Remove every masquerade the backend reports for this interface (one merged + // FamilyAny 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("mergedmove", func(t *testing.T) { + requireCap(t, caps.RuleOrdering) + // A v4/v6 twin collapses to one logical rule that occupies two physical rows. + // Moving it must relocate BOTH rows as a unit: on backends that map a merged + // position to a physical index, a naive move drags only one row and orphans a + // concrete-family twin (grows the chain, splits the merged rule). + 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) + }) + + // The pair merges, so the chain reads as two logical rules. + merged := findRule(t, ctx, mgr, zone, v4) + require.Equal(t, FamilyAny, merged.Family, "the v4/v6 pair should read back as one FamilyAny rule") + + // Move the merged twin to the end (position 2, after b). + require.NoError(t, mgr.MoveRule(ctx, zone, merged, 2)) + order := managedPorts(t, ctx, mgr, zone, []uint16{port, 3511}) + require.Equal(t, []uint16{3511, port}, order, + "the merged twin must move as one unit to the end; an extra port entry means a concrete-family row was orphaned") + require.Equal(t, FamilyAny, findRule(t, ctx, mgr, zone, v4).Family, + "the moved twin must stay merged, not leave a concrete-family orphan") + }) + + t.Run("mergedinsertposition", func(t *testing.T) { + requireCap(t, caps.RuleOrdering) + // GetRules numbers logical (merged) rules, but the chain holds physical rows. + // Inserting before a rule's reported Number must land at that logical position: + // with merged pairs preceding the target, a naive position-1 against physical + // rows skews the placement by the number of absorbed twins. + 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 (no merged twins to skew placement)") + } + 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) + } + }) + + // Two merged pairs plus c read back as three logical rules. Read c's Number + // (its logical position, whatever the backend's add order) and insert d there. + nums := managedNumbers(t, ctx, mgr, zone, []uint16{3522}) + require.Len(t, nums, 1, "c should read back as one logical 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 — not skewed early into the merged pairs. + order := managedPorts(t, ctx, mgr, zone, []uint16{3520, 3521, 3522, 3523}) + require.Len(t, order, 4, "the two pairs, c and d must read back as four logical rules") + 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, after both merged pairs") + }) + + // --- 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})) + }) + + // --- 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. Guards against the nft ensureTable regression. + 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 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") + + 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") + + // 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 live 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. (iptables' + // `ipset destroy` fails with "in use by a kernel component"; the backend must + // surface that 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)) + }) + + // --- 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 that previously only re-touched the backup's own rules and left + // any current rule missing from the backup behind. + 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. + if !orderedFilterBackend(mgr.Type()) { + t.Skip("backend has no first-match chain order to preserve") + } + 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. Regression for a + // Backup that dropped both, so a restore onto a fresh host lost a restrictive + // default policy and left 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("profilescope", func(t *testing.T) { + // The Windows Filtering Platform maps a zone to a firewall profile; AddRule + // and GetRules scope to it, so RemoveRule must too. Removing a rule from one + // profile must not delete an identical rule in another. Only the wf backend + // models per-zone profiles this way; the others share one rule space. + if mgr.Type() != "windows-firewall" { + t.Skip("backend does not scope rules to per-zone profiles") + } + r := &Rule{Proto: TCP, Port: 5303, Action: Accept} + require.NoError(t, mgr.AddRule(ctx, "public", r)) + require.NoError(t, mgr.AddRule(ctx, "private", r)) + t.Cleanup(func() { + _ = mgr.RemoveRule(ctx, "public", r) + _ = mgr.RemoveRule(ctx, "private", r) + }) + + pub, err := mgr.GetRules(ctx, "public") + require.NoError(t, err) + require.True(t, containsRule(pub, r, mgr.Capabilities().Output), "rule should be present in the public profile") + priv, err := mgr.GetRules(ctx, "private") + require.NoError(t, err) + require.True(t, containsRule(priv, r, mgr.Capabilities().Output), "rule should be present in the private profile") + + // Remove from the private profile only; the public copy must survive. + require.NoError(t, mgr.RemoveRule(ctx, "private", r)) + priv, err = mgr.GetRules(ctx, "private") + require.NoError(t, err) + require.False(t, containsRule(priv, r, mgr.Capabilities().Output), "rule should be gone from the private profile") + pub, err = mgr.GetRules(ctx, "public") + require.NoError(t, err) + require.True(t, containsRule(pub, r, mgr.Capabilities().Output), "removing from the private profile must not delete the public rule") + }) + + t.Run("foreignmacsource", func(t *testing.T) { + // firewalld stores a MAC (or ipset) zone source, which GetRules reports as a + // bare-source rule. RemoveRule's source shortcut previously only handled an + // IP/CIDR, so a foreign MAC source could never be removed — Sync/Restore could + // not converge on it. Seed one out of band in the permanent config the backend + // reads and confirm the library both surfaces and removes it. Only firewalld + // models MAC/ipset zone sources this way. + if mgr.Type() != "firewalld" { + t.Skip("backend does not model MAC/ipset zone sources") + } + const mac = "00:11:22:33:44:55" + if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-source="+mac).CombinedOutput(); err != nil { + t.Skipf("could not seed a foreign MAC source (firewall-cmd: %v): %s", err, out) + } + t.Cleanup(func() { + _ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-source="+mac).Run() + }) + + want := &Rule{Source: mac, Action: Accept} + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output), + "a foreign MAC zone source should surface in GetRules") + + require.NoError(t, mgr.RemoveRule(ctx, zone, want)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output), + "RemoveRule must remove a foreign MAC zone source") + }) + + t.Run("foreignprotocol", func(t *testing.T) { + // firewalld stores a bare-protocol allow (firewall-cmd --add-protocol) as a + // zone protocol entry, distinct from the rich-rule form the library writes. + // GetRules must surface it and RemoveRule must remove it, or a foreign + // --add-protocol is invisible to Sync/Restore. Only firewalld models this. + if mgr.Type() != "firewalld" { + t.Skip("backend does not model zone protocol entries") + } + const proto = "gre" + if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-protocol="+proto).CombinedOutput(); err != nil { + t.Skipf("could not seed a foreign protocol (firewall-cmd: %v): %s", err, out) + } + t.Cleanup(func() { + _ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-protocol="+proto).Run() + }) + + want := &Rule{Proto: GRE, Action: Accept} + require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output), + "a foreign zone protocol should surface in GetRules") + + require.NoError(t, mgr.RemoveRule(ctx, zone, want)) + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output), + "RemoveRule must remove a foreign zone protocol") + }) + + t.Run("afterrulesexcluded", func(t *testing.T) { + // ufw writes and removes raw rules only in before.rules, so GetRules must not + // surface a rule from after.rules — otherwise Backup captures it and Restore + // re-adds it into before.rules, duplicating it. Only the ufw backend has this + // before/after split. + if mgr.Type() != "ufw" { + t.Skip("backend has no after.rules file") + } + const afterPath = "/etc/ufw/after.rules" + orig, err := os.ReadFile(afterPath) + if err != nil { + t.Skipf("cannot read after.rules: %v", err) + } + t.Cleanup(func() { _ = os.WriteFile(afterPath, orig, 0o640) }) + + // Inject a parseable rule into the ufw-after-input chain, 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 + } + } + require.True(t, placed, "after.rules should have a COMMIT directive to inject before") + require.NoError(t, os.WriteFile(afterPath, []byte(strings.Join(lines, "\n")), 0o640)) + + probe := &Rule{Family: IPv4, Proto: TCP, Port: 8765, Action: Accept} + require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), probe, mgr.Capabilities().Output), + "a rule in after.rules must not be surfaced by GetRules") + }) +} + +// orderedFilterBackend reports whether a backend's filter rules form a first-match +// chain whose order Restore must reproduce. The list/zone-model backends (csf, apf, +// firewalld, wf) do not order rules this way, so the restoreorder check is skipped +// for them. +func orderedFilterBackend(typ string) bool { + switch typ { + case "nftables", "iptables", "ufw", "pf": + return true + } + return false +} + +// --- 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)) + 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") +} + +// 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() + 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) + 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") + return + } + t.Fatal("backend advertised the capability but rejected every probe form as unsupported") +} + +// 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 +} + +// 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, outputHonored bool) bool { + for _, r := range rules { + if r.EqualBase(want, outputHonored) { + return true + } + if outputHonored && 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/iptables_linux.go b/iptables_linux.go new file mode 100644 index 0000000..81321dd --- /dev/null +++ b/iptables_linux.go @@ -0,0 +1,3162 @@ +package firewall + +import ( + "bufio" + "context" + "errors" + "fmt" + "net" + "os" + "strconv" + "strings" + + "github.com/anmitsu/go-shlex" + dbus "github.com/coreos/go-systemd/dbus" +) + +const ( + // IPTablesType is the manager type string for the iptables backend. + IPTablesType = "iptables" + // 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 their systemd units. +type IPTables struct { + Conn *dbus.Conn + IP4Service string + IP4Path string + IP6Path string + IP6Service 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 systemd units a supported iptables +// packaging uses. The Debian layout carries the same unit for both families +// (netfilter-persistent.service restores both rules.v4 and rules.v6), so +// ip4Service and ip6Service are equal there. +type iptLayout struct { + ip4Path, ip6Path string + ip4Service, ip6Service string +} + +// probeRHELLayout reports the RHEL/iptables-services layout +// (/etc/sysconfig/iptables, iptables.service/ip6tables.service) if its save +// files are both present under root. It does not check root itself, so a +// caller must have already confirmed the RHEL v4 path exists before treating +// an incomplete pair (ok=false) as fatal rather than falling through to +// another layout. root is prepended to both paths so tests can point the +// probe at a temp dir; production callers pass "". +func probeRHELLayout(root string) (l iptLayout, ok bool) { + l = iptLayout{ + ip4Path: "/etc/sysconfig/iptables", ip6Path: "/etc/sysconfig/ip6tables", + ip4Service: "iptables.service", ip6Service: "ip6tables.service", + } + if _, err := os.Stat(root + l.ip4Path); err != nil { + return iptLayout{}, false + } + if _, err := os.Stat(root + l.ip6Path); err != nil { + return iptLayout{}, false + } + return l, true +} + +// probeDebianLayout reports the Debian/Ubuntu iptables-persistent layout +// (/etc/iptables/rules.v4 and rules.v6, both restored by the single +// netfilter-persistent.service) if its save files are both present under +// root. root is prepended to both paths so tests can point the probe at a +// temp dir; production callers pass "". +func probeDebianLayout(root string) (l iptLayout, ok bool) { + l = iptLayout{ + ip4Path: "/etc/iptables/rules.v4", ip6Path: "/etc/iptables/rules.v6", + ip4Service: "netfilter-persistent.service", ip6Service: "netfilter-persistent.service", + } + if _, err := os.Stat(root + l.ip4Path); err != nil { + return iptLayout{}, false + } + if _, err := os.Stat(root + l.ip6Path); err != nil { + return iptLayout{}, false + } + return l, true +} + +// NewIPTables creates an iptables manager, detecting the save-file layout and confirming its systemd units are enabled. +func NewIPTables(ctx context.Context, rulePrefix string) (*IPTables, error) { + ipt := new(IPTables) + ipt.rulePrefix = rulePrefix + + // Prefer the RHEL layout when its v4 save file is present. A v4 file with + // no v6 partner is still a fatal IPTablesNoSave (not a signal to try the + // Debian layout) — we do not know what may be used for the firewall in that + // case. Only when the RHEL v4 path is entirely absent do we probe the + // Debian iptables-persistent layout instead. + var layout iptLayout + if _, err := os.Stat("/etc/sysconfig/iptables"); err == nil { + l, ok := probeRHELLayout("") + if !ok { + return nil, errors.New(IPTablesNoSave) + } + layout = l + } else { + l, ok := probeDebianLayout("") + if !ok { + return nil, errors.New(IPTablesNoSave) + } + layout = l + } + ipt.IP4Path, ipt.IP6Path = layout.ip4Path, layout.ip6Path + + // Connect to systemd dbus interface. + conn, err := dbus.NewWithContext(ctx) + if err != nil { + return nil, fmt.Errorf("failed to connect to systemd: %s", err) + } + ipt.Conn = conn + + // Find the systemd service for loading iptables rules and confirm it was loaded. + ipt.IP4Service = layout.ip4Service + if err := ipt.requireUnitEnabled(ctx, ipt.IP4Service); err != nil { + ipt.Conn.Close() + return nil, err + } + + // If ip6tables service is missing, we do not want to modify iptables + // as we do not know what may be used for the firewall. Skip the redundant + // check when it is the same unit already confirmed enabled above (the + // Debian layout uses one unit for both families). + ipt.IP6Service = layout.ip6Service + if ipt.IP6Service != ipt.IP4Service { + if err := ipt.requireUnitEnabled(ctx, ipt.IP6Service); err != nil { + ipt.Conn.Close() + return nil, err + } + } + + return ipt, nil +} + +// requireUnitEnabled returns IPTablesNoService unless service's UnitFileState +// is "enabled". +func (f *IPTables) requireUnitEnabled(ctx context.Context, service string) error { + prop, err := f.Conn.GetUnitPropertyContext(ctx, service, "UnitFileState") + if err != nil { + return fmt.Errorf("error getting service %s property: %s", service, err) + } + if prop.Value.Value() != "enabled" { + return errors.New(IPTablesNoService) + } + return nil +} + +// Type returns the manager type. +func (f *IPTables) Type() string { + return IPTablesType +} + +// 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 +} + +// 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 +} + +// 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": + // We do not support negation 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 we can parse the address. + _, _, 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 we can parse the address. + _, _, 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": + // We do not support negation 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 + 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": + // We do not support negation 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 (also --ports). + if i+2 >= len(tokens) { + return nil, fmt.Errorf("invalid multiport match") + } + switch tokens[i+1] { + case "--dports", "--dport", "--sports", "--sport", "--ports", "--port": + 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": + // Invalid protocol define. + 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. + // Invalid protocol define. + 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]) + } + + // As rule has been parsed, we may now mark not as false. + 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 +} + +// 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, ",") +} + +// 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, ",") +} + +// iptParsePorts parses a multiport value list (comma-separated "p" or "lo:hi") +// into PortRange values. +func iptParsePorts(val string) ([]PortRange, error) { + return ParsePortRanges(val, ",") +} + +// 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 +} + +// 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. +func combineComment(prefix, comment string) string { + if prefix == "" { + return comment + } + if comment == "" { + return prefix + } + return prefix + " " + comment +} + +// 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). +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 +} + +// 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) +} + +// 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 +// 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" +} + +// iptablesRuleValid reports whether a filter rule can be expressed directly in +// iptables. A port match requires a concrete port-carrying protocol (TCP/UDP/SCTP), +// and an ICMP type is only meaningful on an ICMP protocol, so both are rejected +// rather than silently emitting an invalid iptables-save line. +func iptablesRuleValid(r *Rule) error { + if r.PortNeedsConcreteProtocol() { + return fmt.Errorf("a port requires a tcp, udp, or sctp protocol") + } + if err := r.checkICMPType(); err != nil { + return err + } + return nil +} + +// it so a logged rule's two lines carry identical matches. +func (f *IPTables) marshalMatches(r *Rule) ([]string, error) { + if err := iptablesRuleValid(r); err != nil { + return nil, err + } + + // 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. Reject only the pairings the chain cannot express. + if r.IsOutput() && r.InInterface != "" { + return nil, fmt.Errorf("an input interface cannot be matched on an output rule") + } + if r.IsInput() && r.OutInterface != "" { + return nil, fmt.Errorf("an output interface cannot be matched on an input rule") + } + 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 +} + +// 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) +} + +// chainRules parses the `-A` lines of chain in the *filter table of lines, in +// file order, returning one entry per line (nil for a line the parser rejects, +// which counts as an ordinary foreign rule). It scopes to the *filter table so a +// *nat/*mangle INPUT/OUTPUT chain is never counted, and feeds logicalStarts. +func (f *IPTables) chainRules(lines []string, chain string) []*Rule { + var rules []*Rule + filterFound := false + for _, raw := range lines { + line := strings.TrimSpace(raw) + if !filterFound { + if line == "*filter" { + filterFound = true + } + continue + } + // Leave the filter table at its COMMIT or the next table header so a + // *nat/*mangle INPUT/OUTPUT chain is not counted. + if strings.HasPrefix(line, "*") || line == "COMMIT" { + break + } + if f.chainOf(f.ruleLineBody(line)) != chain { + continue + } + rule, _ := f.UnmarshalRule(line, FamilyAny) + rules = append(rules, rule) + } + return rules +} + +// logicalStarts maps each entry of rules (the parsed `-A` lines of one chain, +// in file order) to the 1-based logical-rule position it begins, or 0 when it is +// not a logical-rule start — an action line coalesced into a preceding LOG line, +// or a dropped orphan LOG line. It mirrors coalesceLoggedRules exactly so an +// insert/move position aligns with the per-chain numbering GetRules reports: a +// LOG line paired with its action is one logical rule beginning at the LOG line, +// while an orphan LOG line (no matching action after it) begins none. The +// returned slice is indexed 1:1 with rules, so prepareInsertRuleFile and +// prepareMoveRuleFile can look up a physical line's position as they scan. +func (f *IPTables) logicalStarts(rules []*Rule) []int { + starts := make([]int, len(rules)) + pos := 0 + for i := 0; i < len(rules); i++ { + cur := rules[i] + if cur != nil && cur.Action == ActionInvalid && cur.Log { + var next *Rule + if i+1 < len(rules) { + next = rules[i+1] + } + if logPartner(cur, next) { + // A LOG line paired with its action is one logical rule that begins + // at the LOG line; the action partner (starts[i+1]) stays 0. + pos++ + starts[i] = pos + i++ + continue + } + // An orphan LOG line is dropped by GetRules, so it begins no logical rule. + continue + } + pos++ + starts[i] = pos + } + return starts +} + +// logPartner reports whether cur is a standalone LOG line (no terminal action) +// and next is its action partner — the pairing GetRules coalesces into one +// logged rule (see iptSameMatch). next may be nil when cur is the last rule in +// the sequence. It is the shared predicate behind coalesceLoggedRules, +// preservedFilterLines and logicalStarts, which walk the same LOG+action +// pairing over different sequence shapes (parsed rules vs. save-file lines). +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. +func mergeLogPair(logLine, action *Rule) *Rule { + merged := *action + merged.Log = true + merged.LogPrefix = logLine.LogPrefix + return &merged +} + +// 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 +} + +// IgnoreLine reports whether an iptables-save line is a blank, comment, table, chain or COMMIT line to be skipped. +func (*IPTables) IgnoreLine(line string) bool { + if len(line) == 0 { + return true + } + if line[0] == '#' || line[0] == '*' || line[0] == ':' { + return true + } + if line == "COMMIT" { + return true + } + return false +} + +// 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 that do not parse (nat rules, custom chains) are skipped. +func (f *IPTables) parseFilterFile(path string, family Family) ([]*Rule, error) { + fd, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = fd.Close() }() + + var perLine []*Rule + scanner := bufio.NewScanner(fd) + // The save file is a full iptables-save dump, so *nat and *mangle also carry + // INPUT/OUTPUT chains whose plain ACCEPT/DROP/LOG rules would parse as filter + // rules. Track table scope and only read the *filter table so a foreign nat or + // mangle rule does not bleed into GetRules (and is never relocated or removed as + // if it were a filter rule). + inFilter := false + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "*") { + inFilter = line == "*filter" + continue + } + if line == "COMMIT" { + inFilter = false + continue + } + if !inFilter { + continue + } + if f.IgnoreLine(line) { + continue + } + r, err := f.UnmarshalRule(line, family) + if err != nil { + continue + } + // UnmarshalRule already stripped the prefix from the comment and + // set HasPrefix; a second strip here would remove a prefix word a second + // time and truncate a user comment that itself begins with the prefix. + perLine = append(perLine, r) + } + if err := scanner.Err(); err != nil { + return nil, err + } + // Return the coalesced rules; GetRules assigns Number after merging families + // across the two save files (the other callers use the result only for + // EqualBase dedup and never read Number). + return coalesceLoggedRules(perLine), nil +} + +// GetRules returns the existing filter rules from the zone. +func (f *IPTables) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + v4, err := f.parseFilterFile(f.IP4Path, IPv4) + if err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err) + } + rules = append(rules, v4...) + + v6, err := f.parseFilterFile(f.IP6Path, IPv6) + if err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err) + } + rules = append(rules, v6...) + + // Merge rules across families, number per direction (before the direction + // merge, so surviving output rows keep the physical position of their still- + // present twin), then collapse each input/output twin into one DirAny rule. + rules = mergeFamilies(rules) + numberByDirection(rules) + rules = mergeDirections(rules) + + return +} + +// prepareAddRuleFile writes an updated copy of filePath, with r inserted, to a +// staged atomicFile and returns it uncommitted. It returns a nil handle (and nil +// error) when no change is needed because the rule already exists. On error the +// staged file is cleaned up. The caller is responsible for committing a returned +// handle (or aborting it). +func (f *IPTables) prepareAddRuleFile(filePath string, r *Rule) (*atomicFile, error) { + // Skip if an equivalent logical rule already exists (LOG+action lines are + // coalesced, so a logged rule is compared as one unit). + existing, err := f.parseFilterFile(filePath, FamilyAny) + if err != nil { + return nil, err + } + for _, e := range existing { + if e.EqualBase(r, true) { + return nil, nil + } + } + + // Encode the rule's line(s): a logged rule is a LOG line plus an action line. + ruleLines, err := f.marshalRuleLines(r) + if err != nil { + return nil, err + } + + fd, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer func() { _ = fd.Close() }() + + // Stage the rewrite, preserving the save file's mode and ownership. + af, err := newAtomicFile(filePath, 0644) + if err != nil { + return nil, err + } + + // Scan each line to find where we should insert our rule. + scanner := bufio.NewScanner(fd) + writtenRule := false + filterFound := false + writeRule := func() { + for _, l := range ruleLines { + _, _ = fmt.Fprintln(af, l) + } + writtenRule = true + } + for scanner.Scan() { + // Trim line and check if we skip the line. + line := strings.TrimSpace(scanner.Text()) + + // Look for the filter, and skip if not reached. + if !filterFound { + if line == "*filter" { + filterFound = true + } + _, _ = fmt.Fprintln(af, line) + continue + } + + // Insert the new rule before the first existing rule of any chain (or + // before COMMIT when the table has no rules yet), so AddRule places it at + // the top of the chain. + if !writtenRule { + if line == "COMMIT" { + writeRule() + } else if strings.HasPrefix(f.ruleLineBody(line), "-A ") { + writeRule() + } + } + + // Write the original line back to the new file. + _, _ = fmt.Fprintln(af, line) + } + + // A read error means the staged file is truncated; discard it rather than + // installing a partial ruleset. + if err := scanner.Err(); err != nil { + af.Abort() + return nil, err + } + + // A rule that was never written means the filter table was malformed. + if !writtenRule { + af.Abort() + return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file") + } + + return af, nil +} + +// AddRule adds a rule to the zone. +func (f *IPTables) AddRule(ctx context.Context, zoneName string, r *Rule) error { + return f.applyRuleFiles(r, f.prepareAddRuleFile) +} + +// 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. +func (f *IPTables) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + return f.applyRuleFiles(r, func(path string, r *Rule) (*atomicFile, error) { + return f.prepareInsertRuleFile(path, r, position) + }) +} + +// prepareInsertRuleFile is like prepareAddRuleFile but inserts the rule at the +// given 1-based position within its chain. +func (f *IPTables) prepareInsertRuleFile(filePath string, r *Rule, position int) (*atomicFile, error) { + if position <= 0 { + position = 1 + } + + existing, err := f.parseFilterFile(filePath, FamilyAny) + if err != nil { + return nil, err + } + for _, e := range existing { + if e.EqualBase(r, true) { + return nil, nil + } + } + + ruleLines, err := f.marshalRuleLines(r) + if err != nil { + return nil, err + } + + fd, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer func() { _ = fd.Close() }() + + af, err := newAtomicFile(filePath, 0644) + if err != nil { + return nil, err + } + + // Match the 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. + expectedChain := iptChainForDirection(r.Direction) + + // Precompute the 1-based logical position each in-chain line begins, mirroring + // GetRules' numbering (a LOG+action pair is one logical rule, an orphan LOG + // line is none). Indexing this as the write pass scans keeps the insert aligned + // with the position GetRules reports and never splits a logged rule's two lines. + allLines, err := f.readAllLines(filePath) + if err != nil { + af.Abort() + return nil, err + } + chainStarts := f.logicalStarts(f.chainRules(allLines, expectedChain)) + chainIdx := 0 + + scanner := bufio.NewScanner(fd) + filterFound := false + writtenRule := false + writeRule := func() { + for _, l := range ruleLines { + _, _ = fmt.Fprintln(af, l) + } + writtenRule = true + } + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if !filterFound { + if line == "*filter" { + filterFound = true + } + _, _ = fmt.Fprintln(af, line) + continue + } + + if !writtenRule && f.chainOf(f.ruleLineBody(line)) == expectedChain { + pos := 0 + if chainIdx < len(chainStarts) { + pos = chainStarts[chainIdx] + } + chainIdx++ + if pos == position { + writeRule() + } + } + + if !writtenRule && line == "COMMIT" { + writeRule() + } + + _, _ = fmt.Fprintln(af, line) + } + + // A read error means the staged file is truncated; discard it rather than + // installing a partial ruleset. + if err := scanner.Err(); err != nil { + af.Abort() + return nil, err + } + + if !writtenRule { + af.Abort() + return nil, fmt.Errorf("we were not able to write the new rule to the iptables-save file") + } + + return af, 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 { + return f.applyRuleFiles(r, func(path string, r *Rule) (*atomicFile, error) { + return f.prepareMoveRuleFile(path, r, position) + }) +} + +// prepareMoveRuleFile removes the first matching rule and re-inserts it at the +// given 1-based position within its chain. +func (f *IPTables) prepareMoveRuleFile(filePath string, r *Rule, position int) (*atomicFile, error) { + if position <= 0 { + position = 1 + } + + lines, err := f.readAllLines(filePath) + if err != nil { + return nil, err + } + + // Exact chain-name compare so a foreign chain whose name starts with + // INPUT/OUTPUT is not counted (see prepareInsertRuleFile). + expectedChain := iptChainForDirection(r.Direction) + + extracted, removedIdx, err := f.extractRuleLines(lines, r) + if err != nil { + return nil, err + } + if removedIdx < 0 { + return nil, nil + } + + without := make([]string, 0, len(lines)-len(extracted)) + for i, l := range lines { + if i >= removedIdx && i < removedIdx+len(extracted) { + continue + } + without = append(without, l) + } + + // Re-insert at the requested 1-based position. A position past the last rule in + // the chain falls through to the COMMIT branch below, which appends after the + // chain's last rule — so no explicit rule count or clamp is needed here (see + // prepareInsertRuleFile, which relies on the same COMMIT fallback). + // Precompute the 1-based logical position each in-chain line begins over the + // post-removal lines, mirroring GetRules' numbering so the re-inserted rule + // lands at the requested position and never between a LOG line and its action. + chainStarts := f.logicalStarts(f.chainRules(without, expectedChain)) + chainIdx := 0 + + out := make([]string, 0, len(without)+len(extracted)) + filterFound := false + inserted := false + for _, raw := range without { + line := strings.TrimSpace(raw) + + if !filterFound { + if line == "*filter" { + filterFound = true + } + out = append(out, raw) + continue + } + + if !inserted && f.chainOf(f.ruleLineBody(line)) == expectedChain { + pos := 0 + if chainIdx < len(chainStarts) { + pos = chainStarts[chainIdx] + } + chainIdx++ + if pos == position { + out = append(out, extracted...) + inserted = true + } + } + if !inserted && line == "COMMIT" { + out = append(out, extracted...) + inserted = true + } + out = append(out, raw) + } + + if !inserted { + return nil, fmt.Errorf("we were not able to move the rule in the iptables-save file") + } + + return f.stageLines(filePath, out) +} + +// extractRuleLines returns the raw save-file lines belonging to the first rule +// equal to r, the index where they start, or a negative index when the rule is +// not present. It coalesces LOG+action lines for logged rules. +func (f *IPTables) extractRuleLines(lines []string, r *Rule) ([]string, int, error) { + var result []string + var resultIdx int + filterFound := false + var pendingLog string + var pendingIdx int + var pendingRule *Rule + flushPending := func() { + pendingRule = nil + } + + for i, raw := range lines { + line := strings.TrimSpace(raw) + + if !filterFound { + if line == "*filter" { + filterFound = true + } + continue + } + + // Leave filter scope at the table's COMMIT or the next table header. The save + // file is a full iptables-save dump, so *nat/*mangle also carry INPUT/OUTPUT + // chains; without this a foreign nat/mangle rule that parses would be extracted + // (and later removed from its real table and spliced into *filter) as if it + // were a filter rule. Mirrors prepareRemoveRuleFile's scoping. + if strings.HasPrefix(line, "*") || line == "COMMIT" { + flushPending() + if line != "*filter" { + filterFound = false + } + continue + } + + if f.IgnoreLine(line) { + flushPending() + continue + } + + rule, err := f.UnmarshalRule(line, FamilyAny) + if err != nil { + flushPending() + continue + } + + if rule.Action == ActionInvalid && rule.Log { + flushPending() + pendingLog = raw + pendingIdx = i + pendingRule = rule + continue + } + + logical := rule + coalesced := logPartner(pendingRule, rule) + if coalesced { + logical = mergeLogPair(pendingRule, rule) + } + + if logical.EqualBase(r, true) { + // Only bundle the held LOG line when it is actually this rule's LOG + // partner (it coalesced). A standalone LOG line that did not coalesce is + // unrelated and must stay where it is, not be dragged with the rule. + if coalesced { + result = []string{pendingLog, raw} + resultIdx = pendingIdx + } else { + result = []string{raw} + resultIdx = i + } + return result, resultIdx, nil + } + flushPending() + } + flushPending() + return nil, -1, nil +} + +// prepareRemoveRuleFile writes a copy of filePath, with r removed, to a staged +// atomicFile and returns it uncommitted. It returns a nil handle (and nil error) +// when the rule was not present so no change is needed. On error the staged file +// is cleaned up. The caller is responsible for committing a returned handle (or +// aborting it). +// +// It shares its rule-location logic with prepareMoveRuleFile: both locate the +// first rule equal to r (and its LOG partner, if any) via extractRuleLines and +// splice those lines out, so the LOG+action pairing and the *nat/*mangle scoping +// it depends on are defined in exactly one place. +func (f *IPTables) prepareRemoveRuleFile(filePath string, r *Rule) (*atomicFile, error) { + lines, err := f.readAllLines(filePath) + if err != nil { + return nil, err + } + + extracted, removedIdx, err := f.extractRuleLines(lines, r) + if err != nil { + return nil, err + } + if removedIdx < 0 { + // The rule was not present; no change is needed. + return nil, nil + } + + without := make([]string, 0, len(lines)-len(extracted)) + for i, l := range lines { + if i >= removedIdx && i < removedIdx+len(extracted) { + continue + } + without = append(without, l) + } + + return f.stageLines(filePath, without) +} + +// RemoveRule removes a rule from the zone. +func (f *IPTables) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + return f.applyRuleFiles(r, f.prepareRemoveRuleFile) +} + +// AddRulesBatch adds every rule in a single rewrite of each family's save file, +// rather than one read-modify-write per rule. It implements RuleBatcher. +func (f *IPTables) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { + return f.applyRulesBatch(rules, false) +} + +// ReplaceRulesBatch rewrites each family's filter table to hold exactly rules, +// preserving the nat table and chain policies. It implements RuleBatcher. +func (f *IPTables) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { + return f.applyRulesBatch(rules, true) +} + +// applyRulesBatch rewrites the *filter table of each family file so it holds the +// requested rules. When replace is false the existing filter rules are kept and +// the new rules appended (skipping duplicates); when true the filter rules are +// replaced outright. The nat table and chain-policy lines are preserved. +func (f *IPTables) applyRulesBatch(rules []*Rule, replace bool) error { + // Fan each DirAny rule out into an input row plus its swapped output row before + // the per-family loop, so each half marshals into its own chain. + var expanded []*Rule + for _, r := range rules { + expanded = append(expanded, expandDirections(r)...) + } + rules = expanded + + for _, fam := range []Family{IPv4, IPv6} { + path := f.IP4Path + if fam == IPv6 { + path = f.IP6Path + } + + // Assemble the desired rule set for this family. + var desired []*Rule + if !replace { + existing, err := f.parseFilterFile(path, fam) + if err != nil { + return err + } + desired = append(desired, existing...) + } + for _, r := range rules { + rf := r.impliedFamily() + if rf != FamilyAny && rf != fam { + continue + } + c := *r + if c.Family == FamilyAny { + c.Family = fam + } + dup := false + for _, e := range desired { + if e.EqualBase(&c, true) { + dup = true + break + } + } + if dup { + continue + } + desired = append(desired, &c) + } + + // Marshal the desired rules to save-file lines. + var ruleLines []string + for _, r := range desired { + rl, err := f.marshalRuleLines(r) + if err != nil { + return err + } + ruleLines = append(ruleLines, rl...) + } + + if err := f.rewriteFilterRules(path, ruleLines); err != nil { + return err + } + } + return nil +} + +// 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 +} + +// 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 "" +} + +// 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 +} + +// preservedFilterLines returns the indices of modeled-chain (INPUT/OUTPUT/ +// FORWARD) -A lines a rewrite must keep verbatim because GetRules cannot represent +// them as a modeled Rule, so they never appear in the desired set and would +// otherwise be dropped. Two kinds qualify, on the same principle the library +// already applies to a foreign chain (a user-defined chain): a rule the library +// does not model, so it must not be deleted just because it is invisible. +// - 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 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. The pairing mirrors coalesceLoggedRules over the same INPUT/OUTPUT +// sequence parseFilterFile builds. +// +// Rules in other chains are excluded here; the caller preserves those verbatim by +// its own chain check. +func (f *IPTables) preservedFilterLines(lines []string) map[int]bool { + type parsed struct { + idx int + rule *Rule // nil when the line does not parse as a modeled rule. + } + var seq []parsed + inFilter := false + for i, line := range lines { + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "*") { + inFilter = t == "*filter" + continue + } + body := f.ruleLineBody(t) + if !inFilter || !strings.HasPrefix(body, "-A ") { + continue + } + // Only modeled-chain lines are decided here; the caller keeps every other + // chain verbatim, so recording them would double-preserve. + if !f.modeledFilterChain(f.chainOf(body)) { + continue + } + r, err := f.UnmarshalRule(t, FamilyAny) + if err != nil { + r = nil // Unmodeled foreign rule: preserve it. + } + seq = append(seq, parsed{i, r}) + } + preserved := map[int]bool{} + for k, p := range seq { + // A line the parser rejects is a foreign rule the desired set cannot + // reproduce; keep it. + if p.rule == nil { + preserved[p.idx] = true + continue + } + if p.rule.Action != ActionInvalid || !p.rule.Log { + continue + } + // A LOG line paired with the action line that follows it is a coalesced + // logged rule the desired set reproduces, so it is not preserved here. + var next *Rule + if k+1 < len(seq) { + next = seq[k+1].rule + } + if logPartner(p.rule, next) { + continue + } + preserved[p.idx] = true + } + return preserved +} + +// 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. +func (f *IPTables) rewriteFilterRules(path string, ruleLines []string) error { + lines, err := f.readAllLines(path) + if err != nil { + return err + } + preserved := f.preservedFilterLines(lines) + out := make([]string, 0, len(lines)+len(ruleLines)) + inFilter := false + inserted := false + for idx, line := range lines { + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(t, "*"): + inFilter = t == "*filter" + out = append(out, line) + case inFilter && t == "COMMIT": + if !inserted { + out = append(out, ruleLines...) + inserted = true + } + out = append(out, line) + inFilter = false + case inFilter && strings.HasPrefix(f.ruleLineBody(t), "-A "): + // The library models the INPUT, OUTPUT and FORWARD chains, so the desired + // set can only ever contain those. Drop an existing modeled rule line + // (counter-annotated or not) — the desired set replaces it — but preserve + // a rule in any other chain (a user-defined chain) verbatim, since + // parseFilterFile never captures those chains and dropping them would + // silently delete rules the library does not manage. A modeled-chain line + // the library cannot model (an unmodeled match or a standalone LOG rule) + // is likewise invisible to GetRules and preserved. + if !f.modeledFilterChain(f.chainOf(f.ruleLineBody(t))) { + out = append(out, line) + } else if preserved[idx] { + out = append(out, line) + } + default: + out = append(out, line) + } + } + if !inserted { + out = append(out, "*filter", ":INPUT ACCEPT [0:0]", ":OUTPUT ACCEPT [0:0]", ":FORWARD ACCEPT [0:0]") + out = append(out, ruleLines...) + out = append(out, "COMMIT") + } + return f.writeAllLines(path, out) +} + +// 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, the *filter table and all other content untouched. A +// file with no *nat table gains one. It is the nat counterpart of +// rewriteFilterRules and is what lets Restore replace managed NAT rules without +// clobbering nat policies or foreign nat rules in other chains. +func (f *IPTables) rewriteNATRules(path string, natLines []string) error { + lines, err := f.readAllLines(path) + if err != nil { + return err + } + out := make([]string, 0, len(lines)+len(natLines)) + inNat := false + inserted := false + for _, line := range lines { + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(t, "*"): + inNat = t == "*nat" + out = append(out, line) + case inNat && t == "COMMIT": + if !inserted { + out = append(out, natLines...) + inserted = true + } + out = append(out, line) + inNat = false + case inNat && strings.HasPrefix(f.ruleLineBody(t), "-A "): + // Drop an existing rule in a managed chain — the desired set replaces it — + // but preserve a rule in a user-defined nat chain verbatim, mirroring how + // rewriteFilterRules preserves FORWARD/custom-chain rules. + if !f.managedNATChain(f.chainOf(f.ruleLineBody(t))) { + out = append(out, line) + } + default: + out = append(out, line) + } + } + if !inserted { + out = append(out, defaultNATSection[:len(defaultNATSection)-1]...) + out = append(out, natLines...) + out = append(out, "COMMIT") + } + return f.writeAllLines(path, out) +} + +// 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 +} + +// 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") + } + + // Recreate the ipsets first so a set-referencing rule (@set) resolves when the + // save files are loaded below. The old rules are still loaded at this point, so + // the sets cannot be removed out from under them; AddAddressSet (ipset -exist) + // creates or repopulates each set idempotently. + 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 + } + + for _, fam := range []Family{IPv4, IPv6} { + path := f.IP4Path + if fam == IPv6 { + path = f.IP6Path + } + + // 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 + } + rl, err := f.marshalRuleLines(&c) + 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 + } + 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) +} + +// applyRuleFiles runs prepare against each family file the rule applies to, +// staging all temp files first and committing them only once every file has +// been prepared successfully. A FamilyAny rule touches both the IPv4 and IPv6 +// files, so staging up front avoids leaving the rule half-applied if the second +// file fails to prepare. +func (f *IPTables) applyRuleFiles(r *Rule, prepare func(string, *Rule) (*atomicFile, error)) error { + // A DirAny rule fans out into an input row plus its role-swapped output row, + // each marshalled into its own chain within the same family file(s). Recurse per + // concrete-direction half; expandDirections returns a single element for a + // concrete rule, so this never recurses more than once. + if subs := expandDirections(r); len(subs) > 1 { + for _, sub := range subs { + if err := f.applyRuleFiles(sub, prepare); err != nil { + return err + } + } + return nil + } + + // Resolve the family, letting an ICMP/ICMPv6 protocol pin it: `-p icmp` + // belongs only in the IPv4 file and `-p icmpv6` only in the IPv6 file. + family := r.impliedFamily() + var paths []string + if family == IPv4 || family == FamilyAny { + paths = append(paths, f.IP4Path) + } + if family == IPv6 || family == FamilyAny { + paths = append(paths, f.IP6Path) + } + + // Stage every file first. + var staged []*atomicFile + for _, path := range paths { + af, err := prepare(path, r) + if err != nil { + // Discard any temp files already staged. + for _, s := range staged { + s.Abort() + } + return err + } + // A nil handle means no change was needed for this file. + if af != nil { + 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 +} + +// stageLines writes lines to a fresh atomicFile for path and returns it +// uncommitted, so a caller staging several family files can commit them together +// once all have been prepared. +func (f *IPTables) stageLines(path string, lines []string) (*atomicFile, error) { + af, err := newAtomicFile(path, 0644) + if err != nil { + return nil, err + } + w := bufio.NewWriter(af) + for _, l := range lines { + _, _ = fmt.Fprintln(w, l) + } + if err := w.Flush(); err != nil { + af.Abort() + return nil, err + } + return af, nil +} + +// Reload reloads the manager to activate new rules. +func (f *IPTables) Reload(ctx context.Context) error { + if err := f.restartUnit(ctx, f.IP4Service); err != nil { + return err + } + + // The Debian layout restores both families from one unit + // (netfilter-persistent.service); restarting it twice is redundant. + if f.IP6Service == f.IP4Service { + return nil + } + return f.restartUnit(ctx, f.IP6Service) +} + +// restartUnit restarts a systemd service and waits for the job to complete. +// The result channel is buffered so the D-Bus goroutine can always deliver +// the job result even if an early return means we never read it. +func (f *IPTables) restartUnit(ctx context.Context, service string) error { + reschan := make(chan string, 1) + _, err := f.Conn.RestartUnitContext(ctx, service, "replace", reschan) + if err != nil { + return fmt.Errorf("failed to restart %s: %s", service, err) + } + if job := <-reschan; job != "done" { + return fmt.Errorf("failed to restart %s: %s", service, "job is not done") + } + return nil +} + +// Close closes the connection to the manager. +func (f *IPTables) Close(ctx context.Context) error { + f.Conn.Close() + return nil +} + +// readAllLines reads every line of an iptables-save file. +func (f *IPTables) readAllLines(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) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} + +// writeAllLines atomically replaces path with the provided lines, preserving +// the existing file's mode and ownership. +func (f *IPTables) writeAllLines(path string, lines []string) error { + af, err := newAtomicFile(path, 0644) + if err != nil { + return err + } + defer af.Abort() + w := bufio.NewWriter(af) + for _, l := range lines { + _, _ = fmt.Fprintln(w, l) + } + if err := w.Flush(); err != nil { + return err + } + return af.Commit() +} + +// fileFamily returns the IP family of one of this backend's save files. +func (f *IPTables) fileFamily(path string) Family { + if path == f.IP6Path { + return IPv6 + } + return IPv4 +} + +// 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) +} + +// 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 +} + +// 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" +} + +// 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 ...`). +func (f *IPTables) MarshalNATRule(r *NATRule) (string, error) { + if err := r.validate(); err != nil { + return "", err + } + + 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") + default: + return "", fmt.Errorf("invalid nat kind") + } + + return strings.Join(parts, " "), nil +} + +// UnmarshalNATRule decodes an iptables-save nat-table rulespec into a NATRule. +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": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid interface parameter") + } + r.Interface = tokens[i] + case "-p", "--protocol": + i++ + if i >= len(tokens) { + return nil, fmt.Errorf("invalid protocol parameter") + } + r.Proto = GetProtocol(tokens[i]) + case "--dport", "--destination-port": + 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": + 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", "--ports", "--port": + default: + 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 +} + +// natRulesInFile parses the nat-table rules from a save file. +func (f *IPTables) natRulesInFile(path string) ([]*NATRule, error) { + lines, err := f.readAllLines(path) + if err != nil { + return nil, err + } + family := f.fileFamily(path) + var rules []*NATRule + inNat := false + for _, raw := range lines { + line := strings.TrimSpace(raw) + if line == "*nat" { + inNat = true + continue + } + if !inNat { + continue + } + if line == "COMMIT" { + inNat = false + continue + } + if f.IgnoreLine(line) { + continue + } + nr, err := f.UnmarshalNATRule(line, family) + if err != nil { + continue + } + rules = append(rules, nr) + } + // Return the rules; GetNATRules assigns Number after merging families across the + // two save files (the other callers use the result only for EqualBase dedup). + return rules, nil +} + +// natPaths returns the save files a NAT rule applies to, per its family. +func (f *IPTables) natPaths(r *NATRule) []string { + fam := r.impliedFamily() + var paths []string + if fam == IPv4 || fam == FamilyAny { + paths = append(paths, f.IP4Path) + } + if fam == IPv6 || fam == FamilyAny { + 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", +} + +// editNATFile inserts or removes a NAT rule line within a save file's nat table, +// creating the table section when adding to a file that lacks one. +func (f *IPTables) editNATFile(path string, r *NATRule, line string, add bool) error { + lines, err := f.readAllLines(path) + if err != nil { + return err + } + + // Locate the nat section and its COMMIT. + natStart, commitIdx := -1, -1 + inNat := false + for i, raw := range lines { + t := strings.TrimSpace(raw) + if t == "*nat" { + natStart = i + inNat = true + continue + } + if inNat && t == "COMMIT" { + commitIdx = i + break + } + } + + if add { + // Skip if an equivalent rule already exists. + existing, err := f.natRulesInFile(path) + if err != nil { + return err + } + for _, e := range existing { + if e.EqualBase(r) { + return nil + } + } + if natStart == -1 || commitIdx == -1 { + // No nat table yet; append a fresh one carrying the rule. + section := make([]string, 0, len(defaultNATSection)+1) + section = append(section, defaultNATSection[:len(defaultNATSection)-1]...) + section = append(section, line, "COMMIT") + lines = append(lines, section...) + return f.writeAllLines(path, lines) + } + // Insert before COMMIT. + updated := make([]string, 0, len(lines)+1) + updated = append(updated, lines[:commitIdx]...) + updated = append(updated, line) + updated = append(updated, lines[commitIdx:]...) + return f.writeAllLines(path, updated) + } + + // Removal: drop the matching nat rule line. + if natStart == -1 || commitIdx == -1 { + return nil + } + updated := make([]string, 0, len(lines)) + removed := false + inNat = false + for _, raw := range lines { + t := strings.TrimSpace(raw) + if t == "*nat" { + inNat = true + updated = append(updated, raw) + continue + } + if inNat && t == "COMMIT" { + inNat = false + updated = append(updated, raw) + continue + } + if inNat && !removed && !f.IgnoreLine(t) { + if nr, perr := f.UnmarshalNATRule(t, f.fileFamily(path)); perr == nil && nr.EqualBase(r) { + removed = true + continue + } + } + updated = append(updated, raw) + } + if !removed { + return nil + } + return f.writeAllLines(path, updated) +} + +// GetNATRules returns the existing NAT rules from the zone. +func (f *IPTables) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { + v4, err := f.natRulesInFile(f.IP4Path) + if err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv4: %s", err) + } + rules = append(rules, v4...) + v6, err := f.natRulesInFile(f.IP6Path) + if err != nil { + return nil, fmt.Errorf("failed to read iptables file for IPv6: %s", err) + } + rules = append(rules, v6...) + // Merge families, then renumber per nat chain so a collapsed v4/v6 pair leaves + // no gap in the derived Number sequence. + merged := mergeNATFamilies(rules) + numberNATByChain(merged) + return merged, nil +} + +// AddNATRule adds a NAT rule to the zone. +func (f *IPTables) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + 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 +} + +// 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 { + 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 +} + +// 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 + } + + // Skip if an equivalent rule already exists. + existing, err := f.natRulesInFile(path) + if err != nil { + return err + } + for _, e := range existing { + if e.EqualBase(r) { + return nil + } + } + + lines, err := f.readAllLines(path) + if err != nil { + return err + } + + // Locate the nat section and its COMMIT. + natStart, commitIdx := -1, -1 + inNat := false + for i, raw := range lines { + t := strings.TrimSpace(raw) + if t == "*nat" { + natStart = i + inNat = true + continue + } + if inNat && t == "COMMIT" { + commitIdx = i + break + } + } + if natStart == -1 || commitIdx == -1 { + // No nat table yet; append a fresh one carrying the rule. + section := make([]string, 0, len(defaultNATSection)+1) + section = append(section, defaultNATSection[:len(defaultNATSection)-1]...) + section = append(section, line, "COMMIT") + lines = append(lines, section...) + return f.writeAllLines(path, lines) + } + + // Find the insertion index: before the position-th rule in the rule's chain, + // or after the chain's last rule when position runs past the end. Default to + // COMMIT so a chain with no existing rules still lands inside the nat table. + // Exact chain-name compare so a foreign chain whose name starts with the + // target chain (e.g. "PREROUTING_direct") is not counted (see + // prepareInsertRuleFile). + chainName := f.natChain(r) + insertAt := commitIdx + lastChainLine := -1 + pos := 0 + for i := natStart + 1; i < commitIdx; i++ { + t := strings.TrimSpace(lines[i]) + if f.chainOf(f.ruleLineBody(t)) == chainName { + lastChainLine = i + pos++ + if pos == position { + insertAt = i + break + } + } + } + if pos < position && lastChainLine >= 0 { + insertAt = lastChainLine + 1 + } + + updated := make([]string, 0, len(lines)+1) + updated = append(updated, lines[:insertAt]...) + updated = append(updated, line) + updated = append(updated, lines[insertAt:]...) + return f.writeAllLines(path, updated) +} + +// 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 +} + +// Capabilities returns the set of features this backend can express. +func (f *IPTables) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + ICMPv6: true, + PortList: true, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: true, + DefaultPolicy: true, + RuleCounters: true, + AddressSets: true, + Comments: 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) { + lines, err := f.readAllLines(path) + if err != nil { + return nil, err + } + 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). Track the table. + inFilter := false + for _, raw := range lines { + if t := strings.TrimSpace(raw); strings.HasPrefix(t, "*") { + inFilter = t == "*filter" + continue + } + if !inFilter { + continue + } + chain, action, ok := f.parsePolicyLine(raw) + if !ok { + continue + } + switch chain { + case "INPUT": + p.Input = action + case "OUTPUT": + p.Output = action + case "FORWARD": + p.Forward = action + } + } + return p, 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 +} + +// 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 { + lines, err := f.readAllLines(path) + if err != nil { + return err + } + updated := make([]string, len(lines)) + // 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. + inFilter := false + for i, raw := range lines { + if t := strings.TrimSpace(raw); strings.HasPrefix(t, "*") { + inFilter = t == "*filter" + updated[i] = raw + continue + } + chain, _, ok := f.parsePolicyLine(raw) + if !ok || !inFilter { + updated[i] = raw + continue + } + var action Action + switch chain { + case "INPUT": + action = policy.Input + case "OUTPUT": + action = policy.Output + case "FORWARD": + action = policy.Forward + default: + updated[i] = raw + continue + } + if action == ActionInvalid { + updated[i] = raw + continue + } + fields := strings.Fields(raw) + counters := "[0:0]" + if len(fields) >= 3 { + counters = fields[2] + } + updated[i] = fmt.Sprintf("%s %s %s", fields[0], strings.ToUpper(action.String()), counters) + } + return f.writeAllLines(path, updated) +} + +// 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 + } + 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 +} + +// 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 []string{f.IP4Path, f.IP6Path} { + if err := f.setPolicyFile(path, policy); err != nil { + return err + } + } + return nil +} + +// --- address sets (ipset) --------------------------------------------------- + +// 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 +} + +// 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 +} + +// GetAddressSets returns the address sets managed by this backend. +func (f *IPTables) GetAddressSets(ctx context.Context) ([]*AddressSet, error) { + out, err := runCommand(ctx, "ipset", "save") + if err != nil { + // ipset not installed, or no sets: nothing to report. + return nil, nil + } + sets := map[string]*AddressSet{} + var names []string + for _, line := range out { + fields := strings.Fields(line) + if len(fields) >= 3 && fields[0] == "create" { + family, t := f.ipsetParseType(fields) + sets[fields[1]] = &AddressSet{Name: fields[1], Family: family, Type: t} + names = append(names, fields[1]) + } + } + for _, line := range out { + fields := strings.Fields(line) + if len(fields) == 3 && fields[0] == "add" { + if set, ok := sets[fields[1]]; ok { + set.Entries = append(set.Entries, fields[2]) + } + } + } + result := make([]*AddressSet, 0, len(names)) + for _, n := range names { + result = append(result, sets[n]) + } + return result, nil +} + +// 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) +} + +// AddAddressSet creates an address set; adding a set that already exists by name is a no-op. +func (f *IPTables) AddAddressSet(ctx context.Context, set *AddressSet) error { + if set == nil || set.Name == "" { + return fmt.Errorf("an address set requires a name") + } + // -exist makes create idempotent (re-create over an existing set). + family := set.Family + if family == FamilyAny { + family = IPv4 + } + args := []string{"create", set.Name} + args = append(args, strings.Fields(f.ipsetTypeSpec(family, set.Type))...) + args = append(args, "-exist") + if _, err := runCommand(ctx, "ipset", args...); err != nil { + return err + } + for _, entry := range set.Entries { + if _, err := runCommand(ctx, "ipset", "add", set.Name, entry, "-exist"); err != nil { + return err + } + } + return nil +} + +// RemoveAddressSet removes an address set by name. +func (f *IPTables) RemoveAddressSet(ctx context.Context, name string) error { + if _, err := runCommand(ctx, "ipset", "flush", name); err != nil { + // A missing set is a no-op; any other flush failure (permission denied, + // set busy) is real and must be surfaced rather than silently proceeding + // to destroy. + if !strings.Contains(err.Error(), "does not exist") { + return err + } + } + _, err := runCommand(ctx, "ipset", "destroy", name) + // A set that was already gone makes removal idempotent. Every other failure — + // notably "Set cannot be destroyed: it is in use by a kernel component" when a + // live rule still references the set — is real and must be surfaced rather than + // reported as success while the set remains. + if err != nil && strings.Contains(err.Error(), "does not exist") { + return nil + } + return err +} + +// AddAddressSetEntry adds an entry to the named set. +func (f *IPTables) AddAddressSetEntry(ctx context.Context, name, entry string) error { + _, err := runCommand(ctx, "ipset", "add", name, entry, "-exist") + return err +} + +// RemoveAddressSetEntry removes an entry from the named set. +func (f *IPTables) RemoveAddressSetEntry(ctx context.Context, name, entry string) error { + _, err := runCommand(ctx, "ipset", "del", name, entry, "-exist") + if err != nil && strings.Contains(err.Error(), "does not exist") { + return nil + } + return err +} diff --git a/iptables_linux_test.go b/iptables_linux_test.go new file mode 100644 index 0000000..3427235 --- /dev/null +++ b/iptables_linux_test.go @@ -0,0 +1,1801 @@ +package firewall + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +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 67.227.233.116 -p tcp -m tcp --dport 4789 -j ACCEPT`, + `-A OUTPUT -d 67.227.233.116 -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 marshalling + // must error rather than silently emit an invalid rule. + _, err = fw.MarshalRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}) + require.Error(t, err, "expected error marshalling a port with no protocol") +} + +func TestIPTablesRuleValid(t *testing.T) { + cases := []struct { + name string + rule *Rule + wantErr bool + }{ + {"port with no protocol", &Rule{Port: 80, Action: Accept}, true}, + {"source port with no protocol", &Rule{SourcePort: 80, Action: Accept}, true}, + {"multiport with no protocol", &Rule{Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, true}, + {"icmp type on tcp", &Rule{Proto: TCP, ICMPType: Ptr[uint8](8), Action: Accept}, true}, + {"icmpv6 type on tcp", &Rule{Proto: TCP, ICMPType: Ptr[uint8](128), Action: Accept}, true}, + {"tcp port", &Rule{Proto: TCP, Port: 80, Action: Accept}, false}, + {"udp port", &Rule{Proto: UDP, Port: 53, Action: Accept}, false}, + {"sctp port", &Rule{Proto: SCTP, Port: 80, Action: Accept}, false}, + {"icmp without type", &Rule{Proto: ICMP, Action: Accept}, false}, + {"icmp with type", &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, false}, + {"icmpv6 with type", &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := iptablesRuleValid(c.rule) + if c.wantErr { + require.Error(t, err, "expected rule to be invalid for iptables") + } else { + require.NoError(t, err, "expected rule to be valid for iptables") + } + }) + } +} + +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") + + // Interface/direction mismatches must be rejected. + _, err = fw.MarshalRule(&Rule{Direction: DirOutput, InInterface: "eth0", Action: Accept}) + require.Error(t, err, "expected error matching an input interface on an output rule") + _, err = fw.MarshalRule(&Rule{OutInterface: "eth0", Action: Accept}) + require.Error(t, err, "expected error matching an output interface on an input rule") +} + +// 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") +} + +// TestIPTablesGetRulesIgnoresNonFilterTables guards against the *nat/*mangle +// tables' INPUT/OUTPUT chains bleeding into the filter read. The save file is a +// full iptables-save dump, so a *mangle INPUT/OUTPUT rule with a plain target +// (ACCEPT/DROP/LOG) parses as a filter rule; a table-agnostic scan would surface it +// via GetRules and, worse, remove it via RemoveRule as if it were a filter rule. +func TestIPTablesGetRulesIgnoresNonFilterTables(t *testing.T) { + dir := t.TempDir() + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + 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" + + "COMMIT\n" + + "*mangle\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n" + + "-A INPUT -p tcp -m tcp --dport 25 -j DROP\n" + + "-A OUTPUT -p tcp -m tcp --dport 26 -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() + + // GetRules must surface only the *filter INPUT rule, not the *mangle ones. + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 1, "only the filter INPUT rule should be surfaced, not the mangle rules") + require.EqualValues(t, 22, rules[0].Port) + + // RemoveRule for a rule matching the *mangle INPUT DROP must not touch the mangle + // table; the library manages only *filter, so the mangle rule is preserved. + require.NoError(t, fw.RemoveRule(ctx, "", &Rule{Proto: TCP, Port: 25, Action: Drop})) + data, err := os.ReadFile(p4) + require.NoError(t, err) + require.Contains(t, string(data), "-A INPUT -p tcp -m tcp --dport 25 -j DROP", + "the mangle INPUT rule must be preserved, not removed as if it were a filter rule") + require.Contains(t, string(data), "-A OUTPUT -p tcp -m tcp --dport 26 -j ACCEPT", + "the mangle OUTPUT rule must be preserved") + require.Contains(t, string(data), "-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT", + "the filter INPUT rule must be preserved") +} + +// TestIPTablesAddRulesBatchPreservesUnmodeledRule verifies the additive batch add +// keeps a pre-existing INPUT/OUTPUT rule the parser cannot model (here an -m recent +// rate-limit rule), matching the single-rule AddRule path. GetRules cannot +// represent such a rule, so a rewrite that dropped it would silently delete a +// foreign rule that AddRulesBatch's contract promises to keep. +func TestIPTablesAddRulesBatchPreservesUnmodeledRule(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, now that the + // forward chain is modeled, the FORWARD dport 8080). + 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.AddRulesBatch(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 batch 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") +} + +func TestIPTablesDefaultPolicy(t *testing.T) { + dir := t.TempDir() + scaffold := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT DROP [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(scaffold), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + pol, err := f.GetDefaultPolicy(ctx, "") + require.NoError(t, err) + require.Equal(t, Accept, pol.Input) + require.Equal(t, Drop, pol.Output) + require.Equal(t, Accept, pol.Forward) + + // Set only the input direction. + require.NoError(t, f.SetDefaultPolicy(ctx, "", &DefaultPolicy{Input: Drop})) + pol, err = f.GetDefaultPolicy(ctx, "") + require.NoError(t, err) + require.Equal(t, Drop, pol.Input, "input policy should be drop after set") + require.Equal(t, Drop, pol.Output, "output policy left unchanged") + require.Equal(t, Accept, pol.Forward, "forward policy left unchanged") + + // The change is applied to both family files. + p6pol, err := f.policyFromFile(p6) + require.NoError(t, err) + require.Equal(t, Drop, p6pol.Input, "policy applied to the v6 file too") + + // Reject is not a valid iptables chain policy. + require.Error(t, f.SetDefaultPolicy(ctx, "", &DefaultPolicy{Input: Reject})) +} + +// TestIPTablesDefaultPolicyIgnoresNATTable guards against the *nat table's +// built-in :INPUT/:OUTPUT ACCEPT chains shadowing the real *filter policy. Both +// real iptables-save and this library's own Restore emit the nat table after the +// filter table, so a table-agnostic scan would let nat's ACCEPT overwrite a +// hardened filter DROP on read, and stamp a non-ACCEPT policy onto the nat +// built-in chains on write (which iptables-legacy-restore rejects). +func TestIPTablesDefaultPolicyIgnoresNATTable(t *testing.T) { + dir := t.TempDir() + // Filter is DROP on input/output; the nat table declares its own chains ACCEPT + // and comes afterwards, exactly as iptables-save lays it out. + save := "*filter\n:INPUT DROP [0:0]\n:OUTPUT DROP [0:0]\n:FORWARD 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" + 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(save), 0644)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // The filter DROP must win over the nat table's later ACCEPT. + pol, err := f.GetDefaultPolicy(ctx, "") + require.NoError(t, err) + require.Equal(t, Drop, pol.Input, "filter INPUT DROP must not be shadowed by nat :INPUT ACCEPT") + require.Equal(t, Drop, pol.Output, "filter OUTPUT DROP must not be shadowed by nat :OUTPUT ACCEPT") + + // Setting a policy must only touch the filter chains, leaving the nat built-in + // chains ACCEPT (a non-ACCEPT policy on a nat built-in chain is invalid). + require.NoError(t, f.SetDefaultPolicy(ctx, "", &DefaultPolicy{Input: Accept, Output: Accept, Forward: Accept})) + data, err := os.ReadFile(p4) + require.NoError(t, err) + require.Contains(t, string(data), "*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]", + "the nat table's built-in chains must stay ACCEPT after a policy write") +} + +func TestIPSetTypeSpec(t *testing.T) { + f := new(IPTables) + require.Equal(t, "hash:ip family inet", f.ipsetTypeSpec(IPv4, SetHashIP)) + require.Equal(t, "hash:net family inet6", f.ipsetTypeSpec(IPv6, SetHashNet)) + // FamilyAny resolves to IPv4. + require.Equal(t, "hash:ip family inet", f.ipsetTypeSpec(FamilyAny, SetHashIP)) +} + +// TestIPTablesLayoutDetection covers probeRHELLayout/probeDebianLayout's file +// presence rules directly, without a live D-Bus/systemd (which NewIPTables +// itself requires and which these helpers do not touch). +func TestIPTablesLayoutDetection(t *testing.T) { + // Neither layout present. + root := t.TempDir() + _, ok := probeRHELLayout(root) + require.False(t, ok, "the RHEL layout should not be found with no save files present") + _, ok = probeDebianLayout(root) + require.False(t, ok, "the Debian layout should not be found with no save files present") + + // RHEL layout: both save files present. + root = t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "etc", "sysconfig"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "sysconfig", "iptables"), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "sysconfig", "ip6tables"), nil, 0644)) + l, ok := probeRHELLayout(root) + require.True(t, ok, "the RHEL layout should be found when both save files are present") + require.Equal(t, "iptables.service", l.ip4Service) + require.Equal(t, "ip6tables.service", l.ip6Service) + + // RHEL layout: v4 present but v6 missing is an incomplete pair, not a match. + root = t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "etc", "sysconfig"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "sysconfig", "iptables"), nil, 0644)) + _, ok = probeRHELLayout(root) + require.False(t, ok, "an incomplete RHEL save-file pair should not be reported as a match") + + // Debian layout: both save files present, one shared service for both families. + root = t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "etc", "iptables"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "iptables", "rules.v4"), nil, 0644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "etc", "iptables", "rules.v6"), nil, 0644)) + l, ok = probeDebianLayout(root) + require.True(t, ok, "the Debian layout should be found when both save files are present") + require.Equal(t, "netfilter-persistent.service", l.ip4Service) + require.Equal(t, l.ip4Service, l.ip6Service, "the Debian layout restores both families from one unit") +} + +func TestCombineSplitComment(t *testing.T) { + // strip returns just the user-facing text of a stored comment, discarding the + // prefix signal (the inverse direction of combineComment). + strip := func(prefix, comment string) string { + text, _ := prefixedComment(prefix, comment) + return text + } + + // No prefix: the user comment passes through unchanged. + require.Equal(t, "ssh", combineComment("", "ssh")) + require.Equal(t, "ssh", strip("", "ssh")) + + // Prefix-only tag. + require.Equal(t, "myapp", combineComment("myapp", "")) + require.Equal(t, "", strip("myapp", "myapp")) + + // Prefix + user comment. + require.Equal(t, "myapp ssh", combineComment("myapp", "ssh")) + require.Equal(t, "ssh", strip("myapp", "myapp ssh")) + + // A comment without our prefix belongs to another tool; leave it intact. + require.Equal(t, "external", strip("myapp", "external")) + + // Separator is a single space; a prefix-like substring mid-comment is not + // stripped. + require.Equal(t, "not myapp ssh", strip("myapp", "not myapp ssh")) +} + +// A rule with a user comment keeps the configured prefix as part of the stored +// comment so rules this library creates stay identifiable. +func TestIPTablesCommentCarriesPrefix(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() + + // No user comment: only the prefix is stored. + require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept})) + + // User comment: stored comment is " ". + require.NoError(t, fw.AddRule(ctx, "", &Rule{Family: IPv4, Proto: TCP, Port: 443, Action: Accept, Comment: "https"})) + + // Reading back recovers only the user-facing text. + rules, err := fw.GetRules(ctx, "") + require.NoError(t, err) + byPort := map[uint16]*Rule{} + for _, r := range rules { + byPort[r.Port] = r + } + require.Equal(t, "", byPort[22].Comment, "prefix tag must not surface as a comment") + require.Equal(t, "https", byPort[443].Comment, "user comment should round-trip") + + // The saved file carries the prefix alongside the user comment. + data, err := os.ReadFile(p4) + require.NoError(t, err) + require.Contains(t, string(data), `-m comment --comment "myapp https"`) +} + +// 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) +} + +// iptables-save canonicalizes a bare host address to its /32 (or /128) form, so +// a rule this library wrote as a bare address must still match the canonicalized +// read-back. +func TestIPTablesHostPrefixRoundTrip(t *testing.T) { + orig := &Rule{Family: IPv4, Proto: TCP, Source: "192.168.1.1", Port: 22, Action: Accept} + got, err := unmarshalIPTablesRule("-A INPUT -s 192.168.1.1/32 -p tcp -m tcp --dport 22 -j ACCEPT", IPv4) + require.NoError(t, err) + require.True(t, got.EqualBase(orig, true), "bare host must match iptables-save /32 form; got %q", got.Source) +} + +// 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 previously accepted only a scalar port, so GetRules silently dropped +// the rule (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 was previously not parsed 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") +} + +// After GetRules merges an IPv4/IPv6 pair, the derived Number sequence must stay +// contiguous and unique within a direction rather than carrying the per-family +// numbering forward (which left gaps/duplicates once families were combined). +func TestIPTablesMergedRulesRenumberContiguously(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)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // A mergeable v4/v6 pair (port 22) plus a v4-only and a v6-only rule. All + // input direction. After merge there are three rules: one FamilyAny, one IPv4, + // one IPv6. + rules := []*Rule{ + {Family: IPv4, Port: 22, Proto: TCP, Action: Accept}, + {Family: IPv6, Port: 22, Proto: TCP, Action: Accept}, + {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, + {Family: IPv6, Port: 443, Proto: TCP, Action: Accept}, + } + for _, r := range rules { + require.NoError(t, f.AddRule(ctx, "", r)) + } + + got, err := f.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, got, 3, "the v4/v6 port-22 pair should collapse to one rule") + + // Every input rule's Number must be distinct and form the contiguous set + // {1,2,3} — no gaps, no cross-family duplicates. + nums := map[int]bool{} + var family22 Family = 255 + for _, r := range got { + require.False(t, nums[r.Number], "duplicate Number %d across merged families", r.Number) + nums[r.Number] = true + if r.Port == 22 { + family22 = r.Family + } + } + require.Equal(t, FamilyAny, family22, "the merged port-22 rule is family-agnostic") + require.Equal(t, map[int]bool{1: true, 2: true, 3: true}, nums, "numbers must be contiguous 1..3") +} + +// 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)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + 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 previously-first rule shifts to position 2") + require.Equal(t, 3, byPort[80], "the previously-second rule shifts to position 3") +} + +// TestIPTablesLogicalStarts checks that logicalStarts numbers physical `-A` +// lines the same way GetRules (coalesceLoggedRules) does: a LOG line paired with +// its action is one logical rule beginning at the LOG line, an orphan LOG line +// begins none, and a foreign line the parser rejects (nil) is an ordinary rule. +func TestIPTablesLogicalStarts(t *testing.T) { + f := new(IPTables) + logRule := func(proto Protocol, port uint16) *Rule { + return &Rule{Proto: proto, Port: port, Log: true, Action: ActionInvalid} + } + act := func(proto Protocol, port uint16) *Rule { + return &Rule{Proto: proto, Port: port, Action: Accept} + } + cases := []struct { + name string + rules []*Rule + want []int + }{ + {"plain rules", []*Rule{act(TCP, 22), act(TCP, 80)}, []int{1, 2}}, + {"logged pair is one logical rule", []*Rule{logRule(TCP, 22), act(TCP, 22), act(TCP, 80)}, []int{1, 0, 2}}, + {"single orphan log dropped", []*Rule{act(TCP, 22), logRule(UDP, 53), act(TCP, 80)}, []int{1, 0, 2}}, + {"consecutive orphan logs dropped", []*Rule{logRule(UDP, 53), logRule(UDP, 123), act(TCP, 22), act(TCP, 80)}, []int{0, 0, 1, 2}}, + {"trailing orphan log", []*Rule{act(TCP, 22), logRule(UDP, 53)}, []int{1, 0}}, + {"foreign nil line counts as a rule", []*Rule{nil, act(TCP, 22)}, []int{1, 2}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, f.logicalStarts(tc.rules)) + }) + } +} + +// 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 previously-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 previously-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 previously did not strip the leading +// [pkts:bytes] prefix, so a counter-annotated save file's NAT rules were +// silently dropped from GetNATRules/Backup and could never 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 ReplaceRulesBatch/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} + require.NoError(t, f.ReplaceRulesBatch(context.Background(), "", []*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}})) + + 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 batch rewrite must preserve rules in chains the library does not model. The +// backend manages only INPUT/OUTPUT, and parseFilterFile drops every other chain +// (FORWARD, custom chains) on read — so if the file-rewrite path deletes all -A +// lines, an AddRulesBatch/ReplaceRulesBatch/Sync silently destroys FORWARD rules +// it never surfaced and the caller could not have listed to keep. +// The FORWARD chain is now a modeled direction: GetRules surfaces its rules and a +// batch reconcile manages them like INPUT/OUTPUT, while a rule in a user-defined +// chain is still preserved verbatim. +func TestIPTablesBatchManagesForwardRules(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 batch (AddRules → AddRulesBatch): existing rules survive. + require.NoError(t, f.AddRulesBatch(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", "AddRulesBatch keeps existing FORWARD rules; file:\n%s", got) + require.Contains(t, string(got), "--dport 22", "desired rule must be present") + + // Full replace (ReplaceRulesBatch): 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}, + } + require.NoError(t, f.ReplaceRulesBatch(context.Background(), "", desired)) + got, err = os.ReadFile(p4) + require.NoError(t, err) + require.NotContains(t, string(got), "10.0.0.0/8", "ReplaceRulesBatch reconciles away the unwanted FORWARD rule; file:\n%s", got) + require.NotContains(t, string(got), "9999", "ReplaceRulesBatch 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 (regression guard). + 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") +} + +// A batch rewrite 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 AddRulesBatch silently deletes a foreign audit-log rule. +func TestIPTablesBatchPreservesOrphanLog(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.AddRulesBatch(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", "AddRulesBatch 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 batch") + 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; extractRuleLines 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, IP6Path: p4} + + // The dport-25 rule lives only in *mangle. Moving it must be a no-op on *filter. + af, err := fw.prepareMoveRuleFile(p4, &Rule{Proto: TCP, Port: 25, Action: Drop}, 1) + require.NoError(t, err) + if af == nil { + return // no-op: correct — the mangle rule was not matched as a filter rule + } + require.NoError(t, af.Commit()) + data, err := os.ReadFile(p4) + require.NoError(t, err) + text := string(data) + filterPart := text[strings.Index(text, "*filter"):strings.Index(text, "*mangle")] + require.NotContains(t, filterPart, "--dport 25", "the *mangle rule must not be spliced into *filter") + manglePart := text[strings.Index(text, "*mangle"):] + require.Contains(t, manglePart, "--dport 25", "the *mangle rule must be preserved in *mangle") +} + +// 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{} + // Remove the unlogged tcp/22 rule; the standalone audit LOG line is unrelated. + target := &Rule{Proto: TCP, Port: 22, Action: Accept} + af, err := f.prepareRemoveRuleFile(path, target) + require.NoError(t, err) + require.NotNil(t, af, "the rule should have been found and removed") + require.NoError(t, af.Commit()) + + 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 extraction path has the mirror defect: a standalone `-j LOG` line +// that does not coalesce with the moved rule must not be extracted and dragged to +// the new position. +func TestMoveRuleDoesNotDragUnrelatedLogLine(t *testing.T) { + f := &IPTables{} + lines := []string{ + "*filter", + ":INPUT ACCEPT [0:0]", + `-A INPUT -j LOG --log-prefix "audit "`, + "-A INPUT -p tcp --dport 22 -j ACCEPT", + "COMMIT", + } + target := &Rule{Proto: TCP, Port: 22, Action: Accept} + extracted, idx, err := f.extractRuleLines(lines, target) + require.NoError(t, err) + require.GreaterOrEqual(t, idx, 0, "the rule should have been found") + require.Equal(t, []string{"-A INPUT -p tcp --dport 22 -j ACCEPT"}, extracted, + "only the targeted action line must be extracted, not the unrelated LOG line") +} + +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) + } +} + +func TestIPTablesInsertAndMove(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)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + r1 := &Rule{Family: IPv4, Port: 1, Proto: TCP, Action: Accept} + r2 := &Rule{Family: IPv4, Port: 2, Proto: TCP, Action: Accept} + r3 := &Rule{Family: IPv4, Port: 3, Proto: TCP, Action: Accept} + + require.NoError(t, f.AddRule(ctx, "", r1)) + require.NoError(t, f.AddRule(ctx, "", r2)) + require.NoError(t, f.AddRule(ctx, "", r3)) + // Move the first rule (r1) to the end (position 3). + require.NoError(t, f.MoveRule(ctx, "", r1, 3)) + + rules, err := f.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 3) + require.EqualValues(t, 3, rules[0].Port) + require.EqualValues(t, 2, rules[1].Port) + require.EqualValues(t, 1, rules[2].Port) + + // Move the first rule (r3) to position 2 (between r2 and r1). + require.NoError(t, f.MoveRule(ctx, "", r3, 2)) + rules, err = f.GetRules(ctx, "") + require.NoError(t, err) + require.EqualValues(t, 2, rules[0].Port) + require.EqualValues(t, 3, rules[1].Port) + require.EqualValues(t, 1, rules[2].Port) +} + +// TestIPTablesRuleNumber verifies GetRules/GetNATRules populate the 1-based Number +// per chain, and that a Number read back round-trips through MoveRule. +func TestIPTablesRuleNumber(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)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // Two input rules and two output rules; each direction numbers from 1. + in1 := &Rule{Family: IPv4, Port: 21, Proto: TCP, Action: Accept} + in2 := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept} + out1 := &Rule{Family: IPv4, Direction: DirOutput, Port: 80, Proto: TCP, Action: Accept} + out2 := &Rule{Family: IPv4, Direction: DirOutput, Port: 443, Proto: TCP, Action: Accept} + for _, r := range []*Rule{in1, in2, out1, out2} { + require.NoError(t, f.AddRule(ctx, "", r)) + } + + numByPort := func() map[uint16]int { + rules, err := f.GetRules(ctx, "") + require.NoError(t, err) + m := map[uint16]int{} + for _, r := range rules { + m[r.Port] = r.Number + } + return m + } + + // iptables AddRule prepends, so the most recently added rule in each chain is + // number 1. Input and output chains are numbered independently. + n := numByPort() + require.Equal(t, 1, n[22], "last-added input rule is number 1") + require.Equal(t, 2, n[21], "first-added input rule is number 2") + require.Equal(t, 1, n[443], "output chain numbers independently from 1") + require.Equal(t, 2, n[80]) + + // Number round-trips through MoveRule: move input rule 21 to the front (its + // read-back Number was 2). + require.NoError(t, f.MoveRule(ctx, "", in1, 1)) + n = numByPort() + require.Equal(t, 1, n[21], "input rule 21 moved to the front") + require.Equal(t, 2, n[22], "input rule 22 shifted to number 2") + require.Equal(t, 1, n[443], "an input-chain move leaves output numbering untouched") + require.Equal(t, 2, n[80]) + + // NAT rules number per nat chain: dnat in prerouting, snat in postrouting. + d1 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 81, ToAddress: "10.0.0.1", ToPort: 8081} + d2 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 82, ToAddress: "10.0.0.2", ToPort: 8082} + s1 := &NATRule{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"} + for _, r := range []*NATRule{d1, d2, s1} { + require.NoError(t, f.AddNATRule(ctx, "", r)) + } + natRules, err := f.GetNATRules(ctx, "") + require.NoError(t, err) + natByPort := map[uint16]int{} + var snatNum int + for _, r := range natRules { + if r.Kind == SNAT { + snatNum = r.Number + continue + } + natByPort[r.Port] = r.Number + } + require.Equal(t, 1, natByPort[81], "first prerouting rule is number 1") + require.Equal(t, 2, natByPort[82]) + require.Equal(t, 1, snatNum, "postrouting chain numbers independently from 1") +} + +// TestIPTablesSetReference verifies a non-address Source/Destination is written as +// an ipset match (-m set --match-set) and round-trips through the save file. +func TestIPTablesSetReference(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)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // A non-address Source names an ipset. + r := &Rule{Family: IPv4, Source: "blocklist", Proto: TCP, Port: 22, Action: Drop} + line, err := f.MarshalRule(r) + require.NoError(t, err) + require.Contains(t, line, "-m set --match-set blocklist src") + + require.NoError(t, f.AddRule(ctx, "", r)) + rules, err := f.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 1) + require.Equal(t, "blocklist", rules[0].Source) + require.True(t, rules[0].EqualBase(r, true), "set-reference rule must round-trip") + + // A negated source set uses the set match's internal `!`. + neg := &Rule{Family: IPv4, Source: "!blocklist", Proto: TCP, Port: 25, Action: Drop} + negLine, err := f.MarshalRule(neg) + require.NoError(t, err) + require.Contains(t, negLine, "-m set ! --match-set blocklist src") + gotNeg, err := f.UnmarshalRule(negLine, IPv4) + require.NoError(t, err) + require.Equal(t, "!blocklist", gotNeg.Source) + + // A negated destination set on a NAT rule. + nat := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, Destination: "!allowlist", ToAddress: "10.0.0.5", ToPort: 8080} + spec, err := f.MarshalNATRule(nat) + require.NoError(t, err) + require.Contains(t, spec, "-m set ! --match-set allowlist dst") + got, err := f.UnmarshalNATRule(spec, IPv4) + require.NoError(t, err) + require.Equal(t, "!allowlist", got.Destination) + require.True(t, got.EqualBase(nat), "set-reference NAT rule must round-trip") +} + +func TestIPTablesBackupRestore(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)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + r1 := &Rule{Family: IPv4, Port: 80, Proto: TCP, Action: Accept} + r2 := &Rule{Family: IPv4, Port: 443, Proto: TCP, Action: Accept} + require.NoError(t, f.AddRule(ctx, "", r1)) + require.NoError(t, f.AddRule(ctx, "", r2)) + + backup, err := f.Backup(ctx, "") + require.NoError(t, err) + require.Len(t, backup.Rules, 2) + + require.NoError(t, f.RemoveRule(ctx, "", r1)) + rules, err := f.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 1) + + require.NoError(t, f.Restore(ctx, "", backup)) + rules, err = f.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 2) +} + +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"}, + {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) + } +} + +// TestIPTablesLoggedRuleFile exercises the whole add/read/remove cycle for a +// logged rule through the save files, covering the LOG+action pairing. +func TestIPTablesLoggedRuleFile(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)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + logged := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Log: true, LogPrefix: "ssh"} + require.NoError(t, f.AddRule(ctx, "", logged)) + + rules, err := f.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 1, "logged rule should read back as one logical rule, got %+v", rules) + require.True(t, rules[0].Log) + require.True(t, rules[0].EqualBase(logged, true)) + + // A duplicate add is a no-op. + require.NoError(t, f.AddRule(ctx, "", logged)) + rules, _ = f.GetRules(ctx, "") + require.Len(t, rules, 1, "duplicate add should not double the rule") + + // Removing drops both underlying lines. + require.NoError(t, f.RemoveRule(ctx, "", logged)) + rules, err = f.GetRules(ctx, "") + require.NoError(t, err) + require.Empty(t, rules, "expected no rules after removal, got %+v", rules) +} + +// TestIPTablesNATFile exercises the nat-table add/read/remove cycle. +func TestIPTablesNATFile(t *testing.T) { + dir := t.TempDir() + scaffold := "*filter\n:INPUT 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)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + nat := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080} + require.NoError(t, f.AddNATRule(ctx, "", nat)) + + rules, err := f.GetNATRules(ctx, "") + require.NoError(t, err) + require.Len(t, rules, 1, "nat rule should read back, got %+v", rules) + require.True(t, rules[0].EqualBase(nat)) + + require.NoError(t, f.RemoveNATRule(ctx, "", nat)) + rules, err = f.GetNATRules(ctx, "") + require.NoError(t, err) + require.Empty(t, rules) +} + +// TestIPTablesNATInsert exercises positioned NAT inserts through the save file: +// position counts only rules in the target chain, so a PREROUTING insert never +// disturbs the POSTROUTING order and a past-the-end position appends. +func TestIPTablesNATInsert(t *testing.T) { + dir := t.TempDir() + scaffold := "*filter\n:INPUT 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)) + f := &IPTables{IP4Path: p4, IP6Path: p6} + ctx := context.Background() + + // dnat matches land in PREROUTING; the snat lands in POSTROUTING. + d1 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 81, ToAddress: "10.0.0.1", ToPort: 8081} + d2 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 82, ToAddress: "10.0.0.2", ToPort: 8082} + d3 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 83, ToAddress: "10.0.0.3", ToPort: 8083} + d4 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 84, ToAddress: "10.0.0.4", ToPort: 8084} + snat := &NATRule{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"} + + require.NoError(t, f.AddNATRule(ctx, "", d1)) + require.NoError(t, f.AddNATRule(ctx, "", d2)) + require.NoError(t, f.AddNATRule(ctx, "", snat)) + + // Insert at the front of PREROUTING. + require.NoError(t, f.InsertNATRule(ctx, "", 1, d3)) + // A position past the chain's end appends after its last rule. + require.NoError(t, f.InsertNATRule(ctx, "", 100, d4)) + + // PREROUTING order reflects the inserts; POSTROUTING is untouched. + require.Equal(t, []uint16{83, 81, 82, 84}, natMatchPorts(t, ctx, f, DNAT)) + require.Equal(t, 1, natKindCount(t, ctx, f, SNAT), "snat should survive the prerouting inserts") + + // A duplicate insert is a no-op. + require.NoError(t, f.InsertNATRule(ctx, "", 1, d1)) + require.Equal(t, []uint16{83, 81, 82, 84}, natMatchPorts(t, ctx, f, DNAT)) +} + +// natMatchPorts returns, in backend order, the matched ports of the NAT rules of +// the given kind. +func natMatchPorts(t *testing.T, ctx context.Context, m Manager, kind NATKind) []uint16 { + t.Helper() + rules, err := m.GetNATRules(ctx, "") + require.NoError(t, err) + var out []uint16 + for _, r := range rules { + if r.Kind == kind { + out = append(out, r.Port) + } + } + return out +} + +// natKindCount returns the number of NAT rules of the given kind. +func natKindCount(t *testing.T, ctx context.Context, m Manager, kind NATKind) int { + t.Helper() + rules, err := m.GetNATRules(ctx, "") + require.NoError(t, err) + n := 0 + for _, r := range rules { + if r.Kind == kind { + n++ + } + } + return n +} + +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) + } +} + +// The configured prefix comment is a tag, not a user label: it does not surface +// as a Rule.Comment on read, but a genuine user comment does. +func TestIPTablesPrefixCommentStripped(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)) + f := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"} + ctx := context.Background() + + // A rule with no user comment is tagged with the prefix only. + require.NoError(t, f.AddRule(ctx, "", &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept})) + // A rule with a user comment carries it. + require.NoError(t, f.AddRule(ctx, "", &Rule{Family: IPv4, Port: 443, Proto: TCP, Action: Accept, Comment: "https"})) + + rules, err := f.GetRules(ctx, "") + require.NoError(t, err) + byPort := map[uint16]*Rule{} + for _, r := range rules { + byPort[r.Port] = r + } + require.Equal(t, "", byPort[22].Comment, "prefix tag must not surface as a comment") + require.Equal(t, "https", byPort[443].Comment, "user comment should round-trip") +} + +// tempIPTables builds an iptables backend backed by scratch save files. +func tempIPTables(t *testing.T) *IPTables { + t.Helper() + dir := t.TempDir() + 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(scaffold), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold), 0644)) + return &IPTables{IP4Path: p4, IP6Path: p6} +} + +// AddRules (via the RuleBatcher path), Sync's minimal diff, and ReplaceRules all +// operate correctly against the iptables backend. +func TestAddRulesSyncReplace(t *testing.T) { + f := tempIPTables(t) + ctx := context.Background() + + // AddRules applies the whole batch, preserving a comment. + rules := []*Rule{ + {Family: IPv4, Port: 22, Proto: TCP, Action: Accept}, + {Family: IPv4, Port: 80, Proto: TCP, Action: Accept}, + {Family: IPv4, Port: 443, Proto: TCP, Action: Accept, Comment: "https"}, + } + require.NoError(t, AddRules(ctx, f, "", rules)) + got, err := f.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, got, 3) + + // A duplicate AddRules is a no-op. + require.NoError(t, AddRules(ctx, f, "", rules)) + got, err = f.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, got, 3, "batch add should skip duplicates") + + // Sync toward a new set: keep 22, drop 80 and 443, add 8080. + desired := []*Rule{ + {Family: IPv4, Port: 22, Proto: TCP, Action: Accept}, + {Family: IPv4, Port: 8080, Proto: TCP, Action: Accept}, + } + added, removed, err := Sync(ctx, f, "", desired) + require.NoError(t, err) + require.Equal(t, 1, added) + require.Equal(t, 2, removed) + got, err = f.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, got, 2) + + // A second Sync to the same set is a no-op. + added, removed, err = Sync(ctx, f, "", desired) + require.NoError(t, err) + require.Equal(t, 0, added) + require.Equal(t, 0, removed) + + // ReplaceRules (atomic batcher) sets exactly one rule. + require.NoError(t, ReplaceRules(ctx, f, "", []*Rule{{Family: IPv4, Port: 22, Proto: TCP, Action: Accept}})) + got, err = f.GetRules(ctx, "") + require.NoError(t, err) + require.Len(t, got, 1) + require.EqualValues(t, 22, got[0].Port) +} + +// iptables end-to-end: GetRules surfaces a foreign rule alongside ours with +// HasPrefix flagging which carries our prefix. As a firewall interface, Backup +// captures the full rule state and Sync reconciles every rule, not just ours. +func TestIPTablesHasPrefixAndSync(t *testing.T) { + dir := t.TempDir() + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + scaffold6 := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" + // One rule tagged with our prefix, one untagged rule with no prefix. + body4 := "*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 -m comment --comment \"myapp\" -j ACCEPT\n" + + "-A INPUT -p tcp -m tcp --dport 8080 -j ACCEPT\n" + + "COMMIT\n" + require.NoError(t, os.WriteFile(p4, []byte(body4), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(scaffold6), 0644)) + f := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"} + ctx := context.Background() + + rules, err := f.GetRules(ctx, "") + require.NoError(t, err) + byPort := map[uint16]*Rule{} + for _, r := range rules { + byPort[r.Port] = r + } + require.Contains(t, byPort, uint16(22)) + require.Contains(t, byPort, uint16(8080)) + require.True(t, byPort[22].HasPrefix, "the tagged rule carries our prefix") + require.False(t, byPort[8080].HasPrefix, "the untagged rule does not") + + // Backup captures the full rule state, prefixed or not. + backup, err := f.Backup(ctx, "") + require.NoError(t, err) + require.Len(t, backup.Rules, 2) + + // Sync toward an empty set reconciles the whole firewall: both rules go. + _, removed, err := Sync(ctx, f, "", nil) + require.NoError(t, err) + require.Equal(t, 2, removed) + + after, err := f.GetRules(ctx, "") + require.NoError(t, err) + require.Empty(t, after, "Sync toward nil removes every rule") +} + +// A NAT rule tagged with the prefix reads back with HasPrefix set; an untagged +// one does not, and Backup captures the full NAT rule state. +func TestIPTablesNATHasPrefixFlag(t *testing.T) { + dir := t.TempDir() + p4 := filepath.Join(dir, "iptables") + p6 := filepath.Join(dir, "ip6tables") + empty := "*filter\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\nCOMMIT\n" + body4 := "*nat\n:PREROUTING ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n" + + "-A PREROUTING -p tcp -m tcp --dport 80 -m comment --comment \"myapp\" -j DNAT --to-destination 10.0.0.5:8080\n" + + "-A PREROUTING -p tcp -m tcp --dport 443 -j DNAT --to-destination 10.0.0.6:8443\n" + + "COMMIT\n" + empty + require.NoError(t, os.WriteFile(p4, []byte(body4), 0644)) + require.NoError(t, os.WriteFile(p6, []byte(empty), 0644)) + f := &IPTables{IP4Path: p4, IP6Path: p6, rulePrefix: "myapp"} + ctx := context.Background() + + nat, err := f.GetNATRules(ctx, "") + require.NoError(t, err) + byPort := map[uint16]*NATRule{} + for _, r := range nat { + byPort[r.Port] = r + } + require.Contains(t, byPort, uint16(80)) + require.Contains(t, byPort, uint16(443)) + require.True(t, byPort[80].HasPrefix, "the tagged NAT rule carries our prefix") + require.False(t, byPort[443].HasPrefix, "the untagged NAT rule does not") + + backup, err := f.Backup(ctx, "") + require.NoError(t, err) + require.Len(t, backup.NATRules, 2) +} + +// 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_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..6a20f32 --- /dev/null +++ b/manager_linux.go @@ -0,0 +1,51 @@ +package firewall + +import ( + "context" + "fmt" +) + +// NewManager gets a firewall manager for this server. 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) { + // First check for firewalld as there are no firewall managers that use it. + firewalld, err := NewFirewallD(ctx, rulePrefix) + if err == nil { + return firewalld, nil + } + + // Check if Ubuntu's Uncomplicated Firewall (UFW) is installed. + ufw, err := NewUFW(ctx, rulePrefix) + if err == nil { + return ufw, nil + } + + // Check if ConfigServer Security & Firewall is installed. + csf, err := NewCSF(ctx, rulePrefix) + if err == nil { + return csf, nil + } + + // Check if Advanced Policy Firewall is installed. + apf, err := NewAPF(ctx, rulePrefix) + if err == nil { + return apf, nil + } + + // After checking common firewall managers that use iptables, try iptables. + iptables, err := NewIPTables(ctx, rulePrefix) + if err == nil { + return iptables, nil + } + + // As a last resort, manage nftables directly. This is tried last so that a + // higher-level manager (which may itself be backed by nftables) is preferred + // when one is present. + nft, err := NewNFT(ctx, rulePrefix) + if err == nil { + return nft, nil + } + + // If no manager found via the known managers, fail. + return nil, fmt.Errorf("no firewall manager found") +} diff --git a/manager_windows.go b/manager_windows.go new file mode 100644 index 0000000..cf170c3 --- /dev/null +++ b/manager_windows.go @@ -0,0 +1,16 @@ +package firewall + +import ( + "context" + "fmt" +) + +func NewManager(ctx context.Context, rulePrefix string) (Manager, error) { + // Attempt to connect to WFP. + wf, err := NewWF(ctx, rulePrefix) + if err == nil { + return wf, nil + } + + return nil, fmt.Errorf("no firewall manager found") +} diff --git a/nft_linux.go b/nft_linux.go new file mode 100644 index 0000000..f5381f3 --- /dev/null +++ b/nft_linux.go @@ -0,0 +1,2222 @@ +package firewall + +import ( + "context" + "encoding/json" + "fmt" + "net" + "strconv" + "strings" + "sync" +) + +const ( + // NFTType is the manager type string reported by this backend. + NFTType = "nftables" + // NFTDefaultTable is the table name used when no rule prefix is supplied. + NFTDefaultTable = "go_firewall" +) + +// NFT manages firewall rules through the nftables `nft` command. 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. Reads and writes are scoped to that table. +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 we only run the setup commands 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 +} + +// 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 + } + return name +} + +// NewNFT constructs an nftables-backed Manager, deriving the private table name +// from rulePrefix and verifying the nft command and nf_tables support are usable. +func NewNFT(ctx context.Context, rulePrefix string) (*NFT, error) { + nft := &NFT{table: sanitizeNFTName(rulePrefix)} + + // Confirm the nft command is available and the ruleset can be listed. This + // requires the nf_tables kernel support to be present. + if _, err := runCommand(ctx, "nft", "--version"); err != nil { + return nil, fmt.Errorf("nft command is not available: %s", err) + } + if _, err := runCommand(ctx, "nft", "list", "ruleset"); err != nil { + return nil, fmt.Errorf("unable to list nftables ruleset: %s", err) + } + + return nft, nil +} + +// Type returns the manager type. +func (f *NFT) Type() string { + return NFTType +} + +// GetZone reports no zone; nftables has no interface-to-zone mapping in the model we expose. +func (f *NFT) GetZone(ctx context.Context, iface string) (zoneName string, err error) { + return "", nil +} + +// ensureTable creates the private table and its input/output/forward base chains +// if they do not already exist. `add` is idempotent in nftables, so re-running is +// safe. +// +// The chain declarations deliberately omit an explicit `policy`: `add chain` on +// an existing base chain re-asserts the named properties, so writing `policy +// 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 the clause leaves any existing policy untouched. +// SetDefaultPolicy sets the policy separately via setChainPolicy. +func (f *NFT) ensureTable(ctx context.Context) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.ensured { + return nil + } + cmds := [][]string{ + {"add", "table", "inet", f.table}, + {"add", "chain", "inet", f.table, "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "}"}, + {"add", "chain", "inet", f.table, "output", "{", "type", "filter", "hook", "output", "priority", "0", ";", "}"}, + {"add", "chain", "inet", f.table, "forward", "{", "type", "filter", "hook", "forward", "priority", "0", ";", "}"}, + } + for _, c := range cmds { + if _, err := runCommand(ctx, "nft", c...); err != nil { + return fmt.Errorf("failed to set up nftables table %s: %s", f.table, err) + } + } + f.ensured = true + return nil +} + +// nftFilterChains lists the private table's filter base chains, in the order a +// read enumerates them. +var nftFilterChains = []string{"input", "output", "forward"} + +// 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" +} + +// 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 +} + +// 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. `add` is idempotent, so re-running is safe. +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 + } + cmds := [][]string{ + {"add", "chain", "inet", f.table, "prerouting", "{", "type", "nat", "hook", "prerouting", "priority", "dstnat", ";", "policy", "accept", ";", "}"}, + {"add", "chain", "inet", f.table, "postrouting", "{", "type", "nat", "hook", "postrouting", "priority", "srcnat", ";", "policy", "accept", ";", "}"}, + } + for _, c := range cmds { + if _, err := runCommand(ctx, "nft", c...); err != nil { + return fmt.Errorf("failed to set up nftables nat chains for %s: %s", f.table, err) + } + } + f.natEnsured = true + return nil +} + +// addrExpr encodes a source/destination match value: the negation operator +// (`!= `) when the token starts with "!", followed by the value — a bare address +// or, for a non-address token, a named-set reference `@name`. +func (f *NFT) addrExpr(addr string) string { + neg, bare := splitAddrNeg(addr) + op := "" + if neg { + op = "!= " + } + if isSetRef(addr) { + return op + "@" + bare + } + return op + bare +} + +// stripSetRef drops the '@' nft prints before a named-set reference, yielding +// the bare set name the Rule model stores in Source/Destination. +func (f *NFT) stripSetRef(v string) string { + return strings.TrimPrefix(v, "@") +} + +// l3Match returns the nftables layer-3 keyword (ip/ip6) for the given family, +// inferring it from an address when the family is unspecified. +func (f *NFT) l3Match(family Family, addr string) string { + switch family { + case IPv4: + return "ip" + case IPv6: + return "ip6" + } + // Infer from the address for FamilyAny rules. + bare := strings.TrimPrefix(addr, "!") + ip, _, err := net.ParseCIDR(bare) + if err != nil { + ip = net.ParseIP(bare) + } + if ip != nil && ip.To4() == nil { + return "ip6" + } + return "ip" +} + +// l4Proto returns the protocol keyword nftables accepts after `meta l4proto`. +// For ICMPv6 this is the `icmpv6` spelling. nft lists such a rule back with the +// differently-spelled `ipv6-icmp` form, which protoFromToken decodes on read. +func (f *NFT) l4Proto(p Protocol) string { + return p.String() +} + +// protoFromToken decodes a layer-4 protocol token as it appears in `nft list` +// output. nft always normalizes `meta l4proto` back to its protocol name when +// listing, so this backend never needs to decode one — but the numeric IP +// protocol number is accepted too as defensive tolerance for output this backend +// has not itself observed. It returns ProtocolAny for an unrecognized token. +func (f *NFT) protoFromToken(tok string) Protocol { + if p := GetProtocol(tok); p != ProtocolAny { + return p + } + switch tok { + case "1": + return ICMP + case "58": + return ICMPv6 + case "6": + return TCP + case "17": + return UDP + case "132": + return SCTP + case "47": + return GRE + case "50": + return ESP + case "51": + return AH + } + return ProtocolAny +} + +// collapseSetSpaces removes the spaces nft inserts inside an anonymous set +// literal when it lists a rule (`{ 80, 443 }`), so strings.Fields treats the set +// as a single token (`{80,443}`) — the compact form MarshalRule emits and the set +// parsers expect. Spaces inside a double-quoted string (a rule comment) are left +// untouched so a comment with spaces still round-trips. nft's quoting has no +// backslash-escape mechanism — a `"` unconditionally toggles the quoted state — +// so no escape tracking is needed here. +func (f *NFT) collapseSetSpaces(line string) string { + var b strings.Builder + depth := 0 + inQuote := false + for _, r := range line { + switch { + case r == '"': + inQuote = !inQuote + case inQuote: + // Preserve everything verbatim inside a quoted comment. + case r == '{': + depth++ + case r == '}': + if depth > 0 { + depth-- + } + } + if !inQuote && depth > 0 && (r == ' ' || r == '\t') { + continue + } + b.WriteRune(r) + } + return b.String() +} + +// portExpr renders a destination port match value: a bare port/range for a +// single spec, or an anonymous set `{80,443,1000-2000}` for a list. +func (f *NFT) portExpr(specs []PortRange) string { + if len(specs) == 1 { + return specs[0].String() + } + return "{" + FormatPortRanges(specs, ",") + "}" +} + +// stateExpr renders a ct state match value: a bare name for one state or an +// anonymous set `{established,related}` for several. +func (f *NFT) stateExpr(s ConnState) string { + names := s.Strings() + if len(names) == 1 { + return names[0] + } + return "{" + strings.Join(names, ",") + "}" +} + +// parseSetTokens strips optional `{ }` braces and splits the comma-separated +// members of an nftables anonymous set (or a single bare value). +func (f *NFT) parseSetTokens(tok string) []string { + tok = strings.TrimSpace(tok) + tok = strings.TrimPrefix(tok, "{") + tok = strings.TrimSuffix(tok, "}") + var out []string + for _, m := range strings.Split(tok, ",") { + m = strings.TrimSpace(m) + if m != "" { + out = append(out, m) + } + } + return out +} + +// parsePorts converts a list of nftables port members (e.g. "80", +// "1000-2000") into PortRange values. +func (f *NFT) parsePorts(members []string) ([]PortRange, error) { + var specs []PortRange + for _, m := range members { + pr, err := ParsePortRange(m) + if err != nil { + return nil, err + } + specs = append(specs, pr) + } + return specs, nil +} + +// parseRate parses an nftables rate operand starting at tokens[i] (a +// "/" token) plus an optional "burst packets" suffix. It returns +// the RateLimit and the index of the last token consumed. +func (f *NFT) parseRate(tokens []string, i int) (*RateLimit, int, error) { + if i >= len(tokens) { + return nil, 0, fmt.Errorf("missing rate value") + } + rate, unit, err := parseRateToken(tokens[i]) + if err != nil { + return nil, 0, err + } + rl := &RateLimit{Rate: rate, Unit: unit} + if i+2 < len(tokens) && tokens[i+1] == "burst" { + b, berr := strconv.ParseUint(tokens[i+2], 10, 32) + if berr != nil { + return nil, 0, fmt.Errorf("invalid burst %q", tokens[i+2]) + } + rl.Burst = uint(b) + i += 2 + // Skip a trailing "packets" keyword. + if i+1 < len(tokens) && tokens[i+1] == "packets" { + i++ + } + // nftables applies a default burst of 5 packets to every limit statement + // and prints it back on `nft list` even when none was requested. A rule + // marshaled with Burst 0 (the documented "backend default") would otherwise + // read back as Burst 5 and fail rule-identity comparison, so treat nft's + // default as unset. An explicit Burst of 5 is indistinguishable from the + // default and equivalent to it. + if rl.Burst == 5 { + rl.Burst = 0 + } + } + return rl, i, nil +} + +// natTarget renders an nftables NAT translation target "[:]", +// bracketing an IPv6 address when a port is present. An empty address yields +// ":" (used by redirect). +func (f *NFT) natTarget(fam Family, addr string, port uint16) string { + if addr == "" { + if port != 0 { + return fmt.Sprintf(":%d", port) + } + return "" + } + 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) +} + +// natFamilyKeyword returns the `ip`/`ip6` qualifier nft requires between a +// dnat/snat verb and its `to` in an inet table (e.g. `dnat ip to `). nft +// rejects the unqualified form unless the rule already carries a same-family +// address match, so the write path always emits it and the read path consumes +// it (see UnmarshalNATRule). Redirect and masquerade take no address and need +// no qualifier. +func (f *NFT) natFamilyKeyword(fam Family, addr string) string { + if fam == IPv6 || familyOfAddr(addr) == IPv6 { + return "ip6" + } + return "ip" +} + +// checkQuotable rejects a value containing a double quote. nft's string +// literals (a rule comment, a log prefix) have no escape mechanism at all — a +// `"` unconditionally toggles the quoted state — so there is no way to write one +// containing an embedded quote; nft's own parser errors on the attempt. Rejecting +// it here gives a clear validation error instead of a confusing raw nft syntax +// error from the command itself. +func (f *NFT) checkQuotable(s, field string) error { + if strings.Contains(s, `"`) { + return fmt.Errorf("nftables %s cannot contain a double quote", field) + } + return nil +} + +// MarshalRule encodes a rule as the nftables expression that follows +// `nft add rule inet `. +func (f *NFT) MarshalRule(r *Rule) (chain string, expr string, err error) { + // nftables can only match a port alongside a concrete transport protocol. + if r.PortNeedsConcreteProtocol() { + return "", "", fmt.Errorf("a port requires a tcp or udp protocol") + } + if err := r.checkICMPType(); err != nil { + return "", "", err + } + if err := f.checkQuotable(r.Comment, "comment"); err != nil { + return "", "", err + } + if err := f.checkQuotable(r.LogPrefix, "log prefix"); err != nil { + return "", "", err + } + + chain = "input" + switch r.Direction { + case DirOutput: + chain = "output" + case DirForward: + chain = "forward" + } + + // An input hook can only match the inbound interface; an output hook only the + // outbound one. The forward hook sees both an ingress and an egress interface, + // so it accepts either. + 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") + } + + var parts []string + + // Interface match. + if r.InInterface != "" { + parts = append(parts, fmt.Sprintf("iifname %s", strconv.Quote(r.InInterface))) + } + if r.OutInterface != "" { + parts = append(parts, fmt.Sprintf("oifname %s", strconv.Quote(r.OutInterface))) + } + + // In an inet table the family is only implied when an address match carries + // it (ip/ip6). For a family-specific rule with no address, pin the family + // explicitly so the rule does not silently widen to both families. + if r.Family != FamilyAny && r.Source == "" && r.Destination == "" { + nfproto := "ipv4" + if r.Family == IPv6 { + nfproto = "ipv6" + } + parts = append(parts, "meta nfproto "+nfproto) + } + + // Source address match, honoring negation via a leading '!'. A non-address token + // names a set, referenced as `@name`. + if r.Source != "" { + parts = append(parts, fmt.Sprintf("%s saddr %s", f.l3Match(r.Family, r.Source), f.addrExpr(r.Source))) + } + + // Destination address match. + if r.Destination != "" { + parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(r.Family, r.Destination), f.addrExpr(r.Destination))) + } + + // Protocol / port match. + if r.HasPorts() { + // A concrete protocol is guaranteed by the check above. + parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs()))) + } + srcSpecs := r.SourcePortSpecs() + if len(srcSpecs) > 0 { + parts = append(parts, fmt.Sprintf("%s sport %s", r.Proto.String(), f.portExpr(srcSpecs))) + } + if r.Proto.IsICMP() && r.ICMPType != nil { + // An ICMP type match implies the icmp/icmpv6 protocol. + kw := "icmp" + if r.Proto == ICMPv6 { + kw = "icmpv6" + } + parts = append(parts, fmt.Sprintf("%s type %d", kw, *r.ICMPType)) + } else if r.Proto != ProtocolAny && !r.HasPorts() && len(srcSpecs) == 0 { + parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto)) + } + + // Connection-tracking state match. + if r.State != 0 { + parts = append(parts, "ct state "+f.stateExpr(r.State)) + } + + // 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 { + lim := fmt.Sprintf("limit rate %d/%s", r.RateLimit.Rate, r.RateLimit.Unit) + if r.RateLimit.Burst > 0 { + lim += fmt.Sprintf(" burst %d packets", r.RateLimit.Burst) + } + parts = append(parts, lim) + } + + // Connection limit: `ct count over N` matches while the tracked connection + // count exceeds the limit. Per-source counting needs a named meter, which + // this model does not express. + if r.ConnLimit != nil { + if r.ConnLimit.PerSource { + return "", "", fmt.Errorf("nftables per-source connection limiting requires a named meter, unsupported in this model") + } + parts = append(parts, fmt.Sprintf("ct count over %d", r.ConnLimit.Count)) + } + + // Logging, emitted just before the verdict so the packet is logged and then + // the action is applied. + if r.Log { + if r.LogPrefix != "" { + // A plain double-quote wrap, not strconv.Quote: nft has no backslash-escape + // mechanism, so strconv.Quote's Go-style escaping (doubling a literal + // backslash, etc.) would not round-trip through nft's own quoting. The + // embedded-quote case that would need escaping is rejected above. + parts = append(parts, `log prefix "`+r.LogPrefix+`"`) + } else { + parts = append(parts, "log") + } + } + + // A counter so GetRules can report per-rule packet/byte statistics. The + // counter has no effect on matching and is ignored when comparing rules. + parts = append(parts, "counter") + + // Action verb. + switch r.Action { + case Accept: + parts = append(parts, "accept") + case Drop: + parts = append(parts, "drop") + case Reject: + parts = append(parts, "reject") + default: + return "", "", fmt.Errorf("no valid action was provided") + } + + // An optional user comment, stored as an nftables rule comment. It has no + // effect on matching and is ignored when comparing rules. A plain quote wrap, + // not strconv.Quote — see the log-prefix comment above for why. + if r.Comment != "" { + parts = append(parts, `comment "`+r.Comment+`"`) + } + + return chain, strings.Join(parts, " "), nil +} + +// unquote reverses the quoting MarshalRule applies to a string value (a log +// prefix or comment): a double-quoted token has its surrounding quotes stripped +// literally, not decoded with strconv.Unquote — nft has no backslash-escape +// mechanism, so a Go-style unquote would wrongly reinterpret a literal backslash +// sequence in the value (e.g. "C:\new") as an escape and corrupt it. A token that +// is not a double-quoted string (single-quoted, or bare/older nft) falls back to +// trimQuotes. +func (f *NFT) unquote(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + return trimQuotes(s) +} + +// joinQuoted reassembles a double-quoted value that strings.Fields split on +// its internal whitespace. Given the index of the token that begins the value, +// it returns the unquoted value and the index of the final token consumed. When +// the token at start is not a quoted string it returns that single token +// unquoted. It mirrors the reassembly the comment parser does, so a spaced value +// (a log prefix like "FW DROP: ") round-trips instead of truncating at the space. +func (f *NFT) joinQuoted(tokens []string, start int) (string, int) { + if start >= len(tokens) { + return "", start + } + if !strings.HasPrefix(tokens[start], "\"") { + return f.unquote(tokens[start]), start + } + var parts []string + j := start + for ; j < len(tokens); j++ { + parts = append(parts, tokens[j]) + joined := strings.Join(parts, " ") + // nft's quoting has no escape mechanism, so the first closing quote + // terminates the value. + if len(joined) >= 2 && strings.HasSuffix(joined, "\"") { + break + } + } + if j >= len(tokens) { + j = len(tokens) - 1 + } + return f.unquote(strings.Join(parts, " ")), j +} + +// UnmarshalRule decodes a single rule line from `nft list` output within the +// given chain. It returns the parsed rule and the nftables handle (used to +// delete the rule), or an error for lines it does not understand. +func (f *NFT) UnmarshalRule(line string, chain string) (r *Rule, handle string, err error) { + r = &Rule{Direction: f.directionForChain(chain)} + + tokens := strings.Fields(f.collapseSetSpaces(line)) + for i := 0; i < len(tokens); i++ { + switch tokens[i] { + case "ip", "ip6": + fam := IPv4 + if tokens[i] == "ip6" { + fam = IPv6 + } + r.Family = fam + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete address match") + } + dir := tokens[i] + + // Consume an optional negation operator. + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete address match") + } + neg := "" + if tokens[i] == "!=" { + neg = "!" + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete address match") + } + } else if tokens[i] != "==" { + // Fall through: current token is the value. + } else { + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete address match") + } + } + + switch dir { + case "saddr": + r.Source = neg + f.stripSetRef(tokens[i]) + case "daddr": + r.Destination = neg + f.stripSetRef(tokens[i]) + default: + return nil, "", fmt.Errorf("unsupported address direction: %s", dir) + } + case "iifname", "oifname": + dir := tokens[i] + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete interface match") + } + iface := trimQuotes(tokens[i]) + if dir == "iifname" { + r.InInterface = iface + } else { + r.OutInterface = iface + } + case "ct": + if i+1 >= len(tokens) { + return nil, "", fmt.Errorf("unsupported ct match") + } + switch tokens[i+1] { + case "state": + // ct state + if i+2 >= len(tokens) { + return nil, "", fmt.Errorf("unsupported ct match") + } + i += 2 + state, serr := ParseConnState(f.parseSetTokens(tokens[i])...) + if serr != nil { + return nil, "", serr + } + r.State = state + case "count": + // ct count over N + if i+3 >= len(tokens) || tokens[i+2] != "over" { + return nil, "", fmt.Errorf("unsupported ct count match") + } + n, cerr := strconv.ParseUint(tokens[i+3], 10, 32) + if cerr != nil { + return nil, "", fmt.Errorf("invalid ct count %q", tokens[i+3]) + } + r.ConnLimit = &ConnLimit{Count: uint(n)} + i += 3 + default: + return nil, "", fmt.Errorf("unsupported ct match") + } + case "limit": + // limit rate N/unit [burst M packets] + if i+2 >= len(tokens) || tokens[i+1] != "rate" { + return nil, "", fmt.Errorf("unsupported limit statement") + } + rl, next, lerr := f.parseRate(tokens, i+2) + if lerr != nil { + return nil, "", lerr + } + r.RateLimit = rl + i = next + case "log": + r.Log = true + // Optional `prefix "..."` and `level ` qualifiers follow. + for i+1 < len(tokens) { + if tokens[i+1] == "prefix" && i+2 < len(tokens) { + // The prefix is a quoted string that strings.Fields may have + // split on its internal spaces; reassemble it fully. + r.LogPrefix, i = f.joinQuoted(tokens, i+2) + } else if tokens[i+1] == "level" && i+2 < len(tokens) { + i += 2 + } else { + break + } + } + case "icmp", "icmpv6", "icmp6": + // icmp type N / icmpv6 type N + if tokens[i] == "icmp" { + r.Proto = ICMP + } else { + r.Proto = ICMPv6 + } + if i+1 < len(tokens) && tokens[i+1] == "type" { + i += 2 + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete icmp type match") + } + n, ok := parseICMPTypeFamily(tokens[i], r.Proto == ICMPv6) + if !ok { + return nil, "", fmt.Errorf("invalid icmp type %q", tokens[i]) + } + r.ICMPType = Ptr(n) + } + case "tcp", "udp", "sctp": + r.Proto = GetProtocol(tokens[i]) + // A `dport` or `sport` qualifier may follow. + if i+2 < len(tokens) && (tokens[i+1] == "dport" || tokens[i+1] == "sport") { + src := tokens[i+1] == "sport" + i += 2 + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete port match") + } + members := f.parseSetTokens(tokens[i]) + if len(members) == 0 { + return nil, "", fmt.Errorf("incomplete port match") + } + specs, perr := f.parsePorts(members) + if perr != nil { + return nil, "", perr + } + // Keep the single-port form in Port for a lone discrete port so + // it round-trips against rules built that way. + 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 "meta": + // meta l4proto or meta nfproto + if i+2 >= len(tokens) { + return nil, "", fmt.Errorf("unsupported meta match") + } + switch tokens[i+1] { + case "l4proto": + r.Proto = f.protoFromToken(tokens[i+2]) + case "nfproto": + switch tokens[i+2] { + case "ipv4": + r.Family = IPv4 + case "ipv6": + r.Family = IPv6 + default: + return nil, "", fmt.Errorf("unsupported nfproto: %s", tokens[i+2]) + } + default: + return nil, "", fmt.Errorf("unsupported meta match") + } + i += 2 + case "accept": + r.Action = Accept + case "drop": + r.Action = Drop + case "reject": + r.Action = Reject + // An explicitly-written `reject with ` (e.g. + // `reject with icmp port-unreachable`, `reject with tcp reset`) can appear + // on a foreign rule — nft itself never adds this clause to a bare `reject` + // on read, but a rule authored with one keeps it verbatim. The detail runs + // until the comment or handle marker; consume it so the trailing tokens do + // not fail the parse and drop the rule. + if i+1 < len(tokens) && tokens[i+1] == "with" { + j := i + 2 + for ; j < len(tokens); j++ { + if tokens[j] == "comment" || tokens[j] == "#" { + break + } + } + i = j - 1 + } + case "comment": + // nft prints the rule comment as a double-quoted string, before an + // optional `# handle N` marker. Reassemble it by collecting tokens up + // to the closing quote, so a comment containing spaces — or a literal + // '#', which strings.Fields would otherwise mistake for the handle + // marker and truncate the rule at — round-trips intact. + if i+1 < len(tokens) && strings.HasPrefix(tokens[i+1], "\"") { + var cparts []string + j := i + 1 + for ; j < len(tokens); j++ { + cparts = append(cparts, tokens[j]) + joined := strings.Join(cparts, " ") + // The first closing quote terminates the comment. + if len(joined) >= 2 && strings.HasSuffix(joined, "\"") { + break + } + } + r.Comment = f.unquote(strings.Join(cparts, " ")) + i = j + } else { + // Unquoted (a single bare word, or older nft): take tokens up to + // the handle marker. + var cparts []string + for j := i + 1; j < len(tokens); j++ { + if tokens[j] == "#" { + break + } + cparts = append(cparts, tokens[j]) + } + r.Comment = f.unquote(strings.Join(cparts, " ")) + i += len(cparts) + } + case "#": + // `nft -a` prints the handle after a comment marker: `# handle N`. + case "handle": + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("missing handle value") + } + handle = tokens[i] + case "counter": + // `counter packets N bytes M` (always present on a listed rule that + // has a counter statement). Capture the values for GetRules. + if i+4 < len(tokens) && tokens[i+1] == "packets" && tokens[i+3] == "bytes" { + if pkts, perr := strconv.ParseUint(tokens[i+2], 10, 64); perr == nil { + r.Packets = pkts + } + if by, berr := strconv.ParseUint(tokens[i+4], 10, 64); berr == nil { + r.Bytes = by + } + i += 4 + } + case "packets", "bytes": + // Skip stray counter value tokens (already consumed under counter). + if i+1 < len(tokens) { + i++ + } + default: + return nil, "", fmt.Errorf("unsupported token: %s", tokens[i]) + } + } + + if r.Action == ActionInvalid { + return nil, "", fmt.Errorf("no valid action was provided") + } + return r, handle, nil +} + +// listChain returns every parsed rule (with its nftables handle) in a chain. +func (f *NFT) listChain(ctx context.Context, chain string) (rules []*Rule, handles []string, err error) { + out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain) + if err != nil { + // A missing table simply means there are no rules yet. + if strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist") { + return nil, nil, nil + } + return nil, nil, err + } + + for _, line := range out { + line = strings.TrimSpace(line) + // Only rule lines carry a handle; skip table/chain scaffolding. + if line == "" || !strings.Contains(line, "handle ") { + continue + } + rule, handle, perr := f.UnmarshalRule(line, chain) + if perr != nil { + 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, handle) + } + return rules, handles, nil +} + +// headerName extracts the object name from an `nft -a list ruleset` header +// line such as `table inet foo { # handle 3` or `chain input { # handle 1`: it +// drops the keyword and everything from the opening brace on. nft -a appends +// `{ # handle N` (and sometimes ` progname ...`) to headers, which a naive +// TrimSuffix(line, "{") would leave attached to the name — making a table +// comparison against our own table miss and re-list our rules as foreign. +func (f *NFT) headerName(line, keyword string) string { + name := strings.TrimPrefix(line, keyword+" ") + if i := strings.IndexByte(name, '{'); i >= 0 { + name = name[:i] + } + return strings.TrimSpace(name) +} + +// listForeignRules walks the entire nftables ruleset and returns best-effort +// parsed rules that live outside this backend's own inet table. Because arbitrary +// foreign tables use families and constructs the library's Rule model cannot +// represent, any line that fails to parse 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(ctx context.Context) ([]*Rule, error) { + out, err := runCommand(ctx, "nft", "-a", "list", "ruleset") + if err != nil { + // No ruleset (or nft unavailable for listing): nothing foreign to report. + return nil, nil + } + + ownTable := "inet " + f.table + curTable := "" + curChain := "" + var rules []*Rule + for _, line := range out { + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(t, "table "): + // e.g. "table inet filter { # handle 3" -> "inet filter" + curTable = f.headerName(t, "table") + curChain = "" + case strings.HasPrefix(t, "chain "): + // e.g. "chain input { # handle 1" -> "input" + curChain = f.headerName(t, "chain") + case t == "}": + // Closes the current chain (a table close is harmless: no rule lines + // follow before the next "table" resets the context). + curChain = "" + case strings.Contains(t, "handle "): + // Our own table is read precisely by listChain; skip it here. + if curTable == ownTable { + continue + } + rule, _, perr := f.UnmarshalRule(t, curChain) + if perr != nil || rule == nil { + continue + } + // A rule from another table: record where it came from; it is not ours, + // so HasPrefix stays false. + rule.table = curTable + rules = append(rules, rule) + } + } + return rules, nil +} + +// listOwnRules returns the library's own filter rules from its private table, +// with the IPv4/IPv6 pairs merged. A read does not create the table; listChain +// returns nothing when the table does not yet exist. +func (f *NFT) listOwnRules(ctx context.Context) ([]*Rule, error) { + var rules []*Rule + for _, chain := range nftFilterChains { + chainRules, _, cerr := f.listChain(ctx, chain) + if cerr != nil { + return nil, cerr + } + rules = append(rules, chainRules...) + } + // Merge families first, number per direction (input then output chain) so a + // collapsed v4/v6 pair leaves no gap and each rule's Number matches the + // InsertRule/MoveRule position within its chain, then collapse each input/output + // twin into one DirAny rule (numbering first keeps the surviving pure-output + // rows' physical position). + merged := mergeFamilies(rules) + numberByDirection(merged) + merged = mergeDirections(merged) + return merged, nil +} + +// GetRules returns the existing filter rules from the zone. +func (f *NFT) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) { + // The library's own merged rules, then foreign rules from every other table + // unmerged (their identity spans tables we do not own). + rules, err = f.listOwnRules(ctx) + if err != nil { + return nil, err + } + foreign, ferr := f.listForeignRules(ctx) + if ferr != nil { + return nil, ferr + } + rules = append(rules, foreign...) + return rules, nil +} + +// 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 append path. +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) +} + +// ruleExists reports whether existing already contains a rule matching r. +func (f *NFT) ruleExists(existing []*Rule, r *Rule) bool { + for _, e := range existing { + if e.EqualForDedup(r, true) { + return true + } + } + return false +} + +// natRuleExists is ruleExists for NAT rules. +func (f *NFT) natRuleExists(existing []*NATRule, r *NATRule) bool { + for _, e := range existing { + if e.EqualForDedup(r) { + return true + } + } + return false +} + +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 + } + + chain, expr, err := f.MarshalRule(r) + if err != nil { + return err + } + + // Skip if an equivalent rule already exists. + existing, _, err := f.listChain(ctx, chain) + if err != nil { + return err + } + if f.ruleExists(existing, r) { + return nil + } + + if position >= 1 { + // position is a merged Number; map it back to a physical chain index so a + // merged v4/v6 pair earlier in the chain does not skew the placement. + insPos := mergedInsertIndex(mergedFamilyAnchors(existing), len(existing), position) + args := f.placeArgs(chain, expr, insPos, len(existing)) + _, err = runCommand(ctx, "nft", args...) + return err + } + + args := append([]string{"add", "rule", "inet", f.table, chain}, strings.Fields(expr)...) + _, err = runCommand(ctx, "nft", args...) + return err +} + +// placeArgs builds the nft command that places a rule expression at 0-based +// index insPos in a chain currently holding n rules. nft can insert before an +// existing rule (`insert ... index k`, k in [0,n-1]) or prepend (`insert` with no +// index), but it has no insert-at-end form, so an index at or past the end must +// append with `add rule`. +func (f *NFT) placeArgs(chain, expr string, insPos, n int) []string { + fields := strings.Fields(expr) + switch { + case insPos >= n: + return append([]string{"add", "rule", "inet", f.table, chain}, fields...) + case insPos <= 0: + return append([]string{"insert", "rule", "inet", f.table, chain}, fields...) + default: + return append([]string{"insert", "rule", "inet", f.table, chain, "index", strconv.Itoa(insPos)}, fields...) + } +} + +// 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 + } + + chain := f.chainForDirection(r.Direction) + + if err := f.ensureTable(ctx); err != nil { + return err + } + + rules, handles, err := f.listChain(ctx, chain) + if err != nil { + return err + } + + // Collect every row that belongs to this rule. GetRules merges an IPv4 and IPv6 + // row that differ only in family into one FamilyAny rule, so moving that merged + // rule must relocate BOTH underlying rows. Deleting only the first half and + // re-inserting a single dual-family rule would orphan the twin — the same hazard + // RemoveRule guards against via EqualForRemoval. A concrete-family target + // still moves only its own family. + firstIdx := -1 + var toDelete []string + deleted := make(map[int]bool) + for i, e := range rules { + if e.EqualForRemoval(r, true) { + if firstIdx < 0 { + firstIdx = i + } + toDelete = append(toDelete, handles[i]) + deleted[i] = true + } + } + if firstIdx < 0 { + return nil + } + + // position and each rule's Number live in the merged (post-mergeFamilies) index + // space, while the chain holds physical rows; map through the chain's merged + // anchors so a merged v4/v6 pair does not skew the target. curPos is the target + // rule's current merged position (its anchor is firstIdx, the first matching row). + anchors := mergedFamilyAnchors(rules) + if position > len(anchors) { + position = len(anchors) + } + curPos := 0 + for p, idx := range anchors { + if idx == firstIdx { + curPos = p + 1 + break + } + } + // If moving to the same position, nothing to do. + if position == curPos { + return nil + } + + // nft has no native move; delete every matching row, then re-insert once at the + // target position within the now-reduced chain. + for _, h := range toDelete { + if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", h); err != nil { + return err + } + } + + _, expr, err := f.MarshalRule(r) + if err != nil { + return err + } + // The rows were just deleted, so the chain now holds the remaining rows; map the + // target merged position to a physical index within that reduced chain (appending + // when the target is at or past the end). + reduced := make([]*Rule, 0, len(rules)-len(toDelete)) + for i, e := range rules { + if !deleted[i] { + reduced = append(reduced, e) + } + } + insPos := mergedInsertIndex(mergedFamilyAnchors(reduced), len(reduced), position) + args := f.placeArgs(chain, expr, insPos, len(reduced)) + _, err = runCommand(ctx, "nft", args...) + return err +} + +// 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 + } + + chain := f.chainForDirection(r.Direction) + + // Find the matching rule so we can delete it by its handle. + rules, handles, err := f.listChain(ctx, chain) + if err != nil { + return err + } + // Delete every matching row, not just the first. GetRules merges an IPv4 and + // IPv6 row that differ only in family into one FamilyAny rule, so removing that + // merged rule must clear both underlying rows or its twin is orphaned (and a + // second reconcile pass would be needed to converge). A concrete-family target + // still removes only its own family — see EqualForRemoval. + // A genuine dual-family row is its own merged anchor (FamilyAny rows never merge + // into a pair), so its merged position is where the re-added family must land to + // preserve ordering. Capture the anchors before the delete shifts the chain. + anchors := mergedFamilyAnchors(rules) + var reAdd *Rule + reAddPos := 0 + for i, e := range rules { + if e.EqualForRemoval(r, true) { + if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil { + return err + } + // A concrete-family target that matched a genuine dual-family row (an inet + // rule with no family pin, covering both) would drop both families; re-add + // the untargeted family below, at the dual row's own merged position so the + // surviving family keeps the removed rule's place in the chain. + if s := splitDualRow(e, r); s != nil { + reAdd = s + for p, idx := range anchors { + if idx == i { + reAddPos = p + 1 + break + } + } + } + } + } + if reAdd != nil { + return f.insertRule(ctx, zoneName, reAddPos, reAdd) + } + // Nothing left to remove. + return nil +} + +// AddRulesBatch adds every rule in a single `nft -f` transaction rather than one +// `nft add rule` invocation per rule. Rules that already exist are skipped. The +// whole batch applies atomically. It implements RuleBatcher. +func (f *NFT) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + + // Snapshot each chain so duplicates (existing or within the batch) are + // skipped, mirroring AddRule. + existing := map[string][]*Rule{} + for _, chain := range nftFilterChains { + rs, _, err := f.listChain(ctx, chain) + if err != nil { + return err + } + existing[chain] = rs + } + + var script strings.Builder + n := 0 + for _, top := range rules { + // A DirAny rule fans out into an input row plus its swapped output row. + for _, r := range expandDirections(top) { + chain, expr, err := f.MarshalRule(r) + if err != nil { + return err + } + if f.ruleExists(existing[chain], r) { + continue + } + fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr) + existing[chain] = append(existing[chain], r) + n++ + } + } + if n == 0 { + return nil + } + _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-") + return err +} + +// ReplaceRulesBatch atomically flushes the private table's input and output +// chains and re-adds exactly rules, all in one `nft -f` transaction. Chain hooks +// and policies are preserved (flush chain removes only rules). It implements +// RuleBatcher. +func (f *NFT) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { + if err := f.ensureTable(ctx); err != nil { + return err + } + + var script strings.Builder + fmt.Fprintf(&script, "flush chain inet %s input\n", f.table) + fmt.Fprintf(&script, "flush chain inet %s output\n", f.table) + for _, top := range rules { + // A DirAny rule fans out into an input row plus its swapped output row. + for _, r := range expandDirections(top) { + chain, expr, err := f.MarshalRule(r) + if err != nil { + return err + } + fmt.Fprintf(&script, "add rule inet %s %s %s\n", f.table, chain, expr) + } + } + _, err := runCommandStdin(ctx, script.String(), "nft", "-f", "-") + return err +} + +// Backup captures the filter and NAT rules in this backend's private table. +func (f *NFT) Backup(ctx context.Context, zoneName string) (*Backup, error) { + // Read the private table directly rather than GetRules: Restore flushes and + // 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(ctx) + if err != nil { + return nil, err + } + natRules, err := f.listOwnNATRules(ctx) + 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 + } + + // Flush the private table, then re-add all rules. + if _, err := runCommand(ctx, "nft", "flush", "table", "inet", f.table); err != nil { + return err + } + + // Recreate the sets on a clean slate before the rules that reference them. The + // flush above cleared every rule in the table, so no rule holds a set reference + // and each set can be removed and rebuilt; the clean rebuild is required because + // nft's AddAddressSet is a no-op on an existing set and would not otherwise + // restore a flushed set's elements. + 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) +} + +// MarshalNATRule encodes a NAT rule as the nftables expression that follows +// `nft add rule inet
`, returning the chain (prerouting for +// destination NAT, postrouting for source NAT) and the expression. +func (f *NFT) MarshalNATRule(r *NATRule) (chain string, expr string, err error) { + if err := r.validate(); err != nil { + return "", "", err + } + + fam := r.impliedFamily() + var parts []string + + // Interface, bound to the NAT direction: outbound for source NAT, inbound + // for destination NAT. + if r.Interface != "" { + if r.Kind.isSource() { + parts = append(parts, "oifname "+strconv.Quote(r.Interface)) + } else { + parts = append(parts, "iifname "+strconv.Quote(r.Interface)) + } + } + + // Pin the family when no address carries it. + if fam != FamilyAny && r.Source == "" && r.Destination == "" { + nfproto := "ipv4" + if fam == IPv6 { + nfproto = "ipv6" + } + parts = append(parts, "meta nfproto "+nfproto) + } + + if r.Source != "" { + parts = append(parts, fmt.Sprintf("%s saddr %s", f.l3Match(fam, r.Source), f.addrExpr(r.Source))) + } + if r.Destination != "" { + parts = append(parts, fmt.Sprintf("%s daddr %s", f.l3Match(fam, r.Destination), f.addrExpr(r.Destination))) + } + + if r.HasPorts() { + parts = append(parts, fmt.Sprintf("%s dport %s", r.Proto.String(), f.portExpr(r.PortSpecs()))) + } else if r.Proto != ProtocolAny { + parts = append(parts, "meta l4proto "+f.l4Proto(r.Proto)) + } + + switch r.Kind { + case DNAT: + chain = "prerouting" + parts = append(parts, "dnat "+f.natFamilyKeyword(fam, r.ToAddress)+" to "+f.natTarget(fam, r.ToAddress, r.ToPort)) + case Redirect: + chain = "prerouting" + parts = append(parts, "redirect to "+f.natTarget(fam, "", r.ToPort)) + case SNAT: + chain = "postrouting" + parts = append(parts, "snat "+f.natFamilyKeyword(fam, r.ToAddress)+" to "+f.natTarget(fam, r.ToAddress, 0)) + case Masquerade: + chain = "postrouting" + parts = append(parts, "masquerade") + default: + return "", "", fmt.Errorf("invalid nat kind") + } + + return chain, strings.Join(parts, " "), nil +} + +// parseNATTarget parses an nftables NAT target token ("addr", "addr:port", +// "[v6]:port" or ":port") into its address and port. +func (f *NFT) parseNATTarget(tok string) (addr string, port uint16, err error) { + tok = strings.TrimSpace(tok) + if tok == "" { + return "", 0, nil + } + // Bracketed IPv6, optionally with a port. + if strings.HasPrefix(tok, "[") { + end := strings.Index(tok, "]") + if end < 0 { + return "", 0, fmt.Errorf("invalid nat target %q", tok) + } + addr = tok[1:end] + rest := tok[end+1:] + if strings.HasPrefix(rest, ":") { + p, perr := strconv.ParseUint(rest[1:], 10, 16) + if perr != nil { + return "", 0, fmt.Errorf("invalid nat target port %q", rest[1:]) + } + port = uint16(p) + } + return addr, port, nil + } + // Bare ":port" (redirect target). + if strings.HasPrefix(tok, ":") { + p, perr := strconv.ParseUint(tok[1:], 10, 16) + if perr != nil { + return "", 0, fmt.Errorf("invalid nat target port %q", tok[1:]) + } + return "", uint16(p), nil + } + // A single colon is IPv4 addr:port; more (or none) is a bare address. + if strings.Count(tok, ":") == 1 { + host, ps, _ := strings.Cut(tok, ":") + p, perr := strconv.ParseUint(ps, 10, 16) + if perr != nil { + return "", 0, fmt.Errorf("invalid nat target port %q", ps) + } + return host, uint16(p), nil + } + return tok, 0, nil +} + +// UnmarshalNATRule decodes a single NAT rule line from `nft -a list chain` +// output within the given chain, returning the parsed rule and its handle. +func (f *NFT) UnmarshalNATRule(line string, chain string) (r *NATRule, handle string, err error) { + r = &NATRule{} + tokens := strings.Fields(f.collapseSetSpaces(line)) + for i := 0; i < len(tokens); i++ { + switch tokens[i] { + case "ip", "ip6": + fam := IPv4 + if tokens[i] == "ip6" { + fam = IPv6 + } + r.Family = fam + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete address match") + } + dir := tokens[i] + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete address match") + } + neg := "" + if tokens[i] == "!=" { + neg = "!" + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete address match") + } + } + switch dir { + case "saddr": + r.Source = neg + f.stripSetRef(tokens[i]) + case "daddr": + r.Destination = neg + f.stripSetRef(tokens[i]) + default: + return nil, "", fmt.Errorf("unsupported address direction: %s", dir) + } + case "iifname", "oifname": + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete interface match") + } + r.Interface = trimQuotes(tokens[i]) + case "tcp", "udp", "sctp": + r.Proto = GetProtocol(tokens[i]) + if i+1 < len(tokens) && tokens[i+1] == "dport" { + i += 2 + if i >= len(tokens) { + return nil, "", fmt.Errorf("incomplete port match") + } + specs, perr := f.parsePorts(f.parseSetTokens(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 + } + } + case "meta": + if i+2 >= len(tokens) { + return nil, "", fmt.Errorf("unsupported meta match") + } + switch tokens[i+1] { + case "l4proto": + r.Proto = f.protoFromToken(tokens[i+2]) + case "nfproto": + switch tokens[i+2] { + case "ipv4": + r.Family = IPv4 + case "ipv6": + r.Family = IPv6 + default: + return nil, "", fmt.Errorf("unsupported nfproto: %s", tokens[i+2]) + } + default: + return nil, "", fmt.Errorf("unsupported meta match") + } + i += 2 + case "dnat", "snat": + r.Kind = DNAT + if tokens[i] == "snat" { + r.Kind = SNAT + } + // nft lists the translation with the address family before `to` + // (`dnat ip to `); consume the optional ip/ip6 keyword. + j := i + 1 + if j < len(tokens) && (tokens[j] == "ip" || tokens[j] == "ip6") { + if tokens[j] == "ip6" { + r.Family = IPv6 + } else if r.Family == FamilyAny { + r.Family = IPv4 + } + j++ + } + if j+1 >= len(tokens) || tokens[j] != "to" { + return nil, "", fmt.Errorf("incomplete %s statement", tokens[i]) + } + addr, port, terr := f.parseNATTarget(tokens[j+1]) + if terr != nil { + return nil, "", terr + } + r.ToAddress = addr + r.ToPort = port + i = j + 1 + case "redirect": + r.Kind = Redirect + if i+2 < len(tokens) && tokens[i+1] == "to" { + _, port, terr := f.parseNATTarget(tokens[i+2]) + if terr != nil { + return nil, "", terr + } + r.ToPort = port + i += 2 + } + case "masquerade": + r.Kind = Masquerade + case "#": + // The `# handle N` marker follows. + case "handle": + i++ + if i >= len(tokens) { + return nil, "", fmt.Errorf("missing handle value") + } + handle = tokens[i] + case "counter", "packets", "bytes": + if tokens[i] == "packets" || tokens[i] == "bytes" { + i++ + } + default: + return nil, "", fmt.Errorf("unsupported token: %s", tokens[i]) + } + } + if r.Kind == NATInvalid { + return nil, "", fmt.Errorf("no nat action was provided") + } + if r.Family == FamilyAny { + r.Family = r.impliedFamily() + } + return r, handle, nil +} + +// listNATChain returns every parsed NAT rule (with its handle) in a chain. +func (f *NFT) listNATChain(ctx context.Context, chain string) (rules []*NATRule, handles []string, err error) { + out, err := runCommand(ctx, "nft", "-a", "list", "chain", "inet", f.table, chain) + if err != nil { + if strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist") { + return nil, nil, nil + } + return nil, nil, err + } + for _, line := range out { + line = strings.TrimSpace(line) + if line == "" || !strings.Contains(line, "handle ") { + continue + } + rule, handle, perr := f.UnmarshalNATRule(line, chain) + if perr != nil { + continue + } + // NAT rules live in this backend's own table; membership 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, handle) + } + return rules, handles, nil +} + +// listForeignNATRules walks the entire nftables ruleset and returns best-effort +// NAT rules that live outside this backend's own inet table. Like listForeignRules +// it skips any line it cannot parse. +func (f *NFT) listForeignNATRules(ctx context.Context) ([]*NATRule, error) { + out, err := runCommand(ctx, "nft", "-a", "list", "ruleset") + if err != nil { + return nil, nil + } + + ownTable := "inet " + f.table + curTable := "" + curChain := "" + var rules []*NATRule + for _, line := range out { + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(t, "table "): + curTable = f.headerName(t, "table") + curChain = "" + case strings.HasPrefix(t, "chain "): + curChain = f.headerName(t, "chain") + case t == "}": + curChain = "" + case strings.Contains(t, "handle "): + if curTable == ownTable { + continue + } + rule, _, perr := f.UnmarshalNATRule(t, curChain) + if perr != nil || rule == nil { + continue + } + // A NAT rule from another table: record its source; not ours, so + // HasPrefix stays false. + rule.table = curTable + rules = append(rules, rule) + } + } + return rules, nil +} + +// listOwnNATRules returns the library's own NAT rules from its private table, +// with the IPv4/IPv6 pairs merged. +func (f *NFT) listOwnNATRules(ctx context.Context) ([]*NATRule, error) { + var rules []*NATRule + for _, chain := range []string{"prerouting", "postrouting"} { + chainRules, _, cerr := f.listNATChain(ctx, chain) + if cerr != nil { + return nil, cerr + } + rules = append(rules, chainRules...) + } + // Merge families first, then number per nat chain (prerouting then postrouting) + // so a collapsed v4/v6 pair leaves no gap and each rule's Number matches the + // InsertNATRule position within its chain. + merged := mergeNATFamilies(rules) + numberNATByChain(merged) + return merged, nil +} + +// GetNATRules returns the existing NAT rules from the zone. +func (f *NFT) GetNATRules(ctx context.Context, zoneName string) (rules []*NATRule, err error) { + rules, err = f.listOwnNATRules(ctx) + if err != nil { + return nil, err + } + foreign, ferr := f.listForeignNATRules(ctx) + if ferr != nil { + return nil, ferr + } + rules = append(rules, foreign...) + return rules, nil +} + +// AddNATRule adds a NAT rule to the zone. +func (f *NFT) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := f.ensureNATChains(ctx); err != nil { + return err + } + + chain, expr, err := f.MarshalNATRule(r) + if err != nil { + return err + } + + existing, _, err := f.listNATChain(ctx, chain) + if err != nil { + return err + } + if f.natRuleExists(existing, r) { + return nil + } + + args := append([]string{"add", "rule", "inet", f.table, chain}, strings.Fields(expr)...) + _, err = runCommand(ctx, "nft", args...) + return err +} + +// 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 err := f.ensureNATChains(ctx); err != nil { + return err + } + + chain, expr, err := f.MarshalNATRule(r) + if err != nil { + return err + } + + existing, _, err := f.listNATChain(ctx, chain) + if err != nil { + return err + } + if f.natRuleExists(existing, r) { + return nil + } + + if position <= 0 { + position = 1 + } + // position is a merged Number; map it back to a physical nat-chain index so a + // merged v4/v6 NAT pair earlier in the chain does not skew the placement. + insPos := mergedInsertIndex(mergedNATFamilyAnchors(existing), len(existing), position) + args := f.placeArgs(chain, expr, insPos, len(existing)) + _, err = runCommand(ctx, "nft", args...) + return err +} + +// RemoveNATRule removes a NAT rule from the zone. +func (f *NFT) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := f.ensureNATChains(ctx); err != nil { + return err + } + + chain := "prerouting" + if r.Kind.isSource() { + chain = "postrouting" + } + + rules, handles, err := f.listNATChain(ctx, chain) + if err != nil { + return err + } + // Delete every matching row (see RemoveRule): a merged FamilyAny NAT rule must + // clear both its v4 and v6 rows, while a concrete-family target removes only + // its own family. + for i, e := range rules { + if e.EqualForRemoval(r) { + if _, err := runCommand(ctx, "nft", "delete", "rule", "inet", f.table, chain, "handle", handles[i]); err != nil { + return err + } + } + } + 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 +} + +// Capabilities returns the set of features this backend can express. +func (f *NFT) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + ICMPv6: true, + PortList: true, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: true, + DefaultPolicy: true, + RuleCounters: true, + AddressSets: true, + Comments: true, + } +} + +// chainPolicy reads the policy of one of this backend's base chains. It returns +// ActionInvalid when the table or chain does not yet exist (no policy to +// report) or the policy is not recognized. +func (f *NFT) chainPolicy(ctx context.Context, chain string) (Action, error) { + out, err := runCommand(ctx, "nft", "list", "chain", "inet", f.table, chain) + if err != nil { + // A missing table/chain means no policy to report. + return ActionInvalid, nil + } + for _, line := range out { + line = strings.TrimSpace(line) + switch { + case strings.Contains(line, "policy accept"): + return Accept, nil + case strings.Contains(line, "policy drop"): + return Drop, nil + } + } + return ActionInvalid, nil +} + +// GetDefaultPolicy returns the default action applied to packets that match no rule. +func (f *NFT) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + in, err := f.chainPolicy(ctx, "input") + if err != nil { + return nil, err + } + out, err := f.chainPolicy(ctx, "output") + if err != nil { + return nil, err + } + fwd, err := f.chainPolicy(ctx, "forward") + if err != nil { + return nil, err + } + return &DefaultPolicy{Input: in, Output: out, Forward: fwd}, nil +} + +// setChainPolicy updates the policy of a base chain. nftables chain policies may +// only be accept or drop; reject is not expressible. +func (f *NFT) setChainPolicy(ctx context.Context, chain string, action Action) error { + switch action { + case Accept, Drop: + case Reject: + return fmt.Errorf("nftables chain policy may only be accept or drop") + default: + return fmt.Errorf("invalid default policy action") + } + _, err := runCommand(ctx, "nft", "chain", "inet", f.table, chain, "{", "policy", action.String(), ";", "}") + return err +} + +// SetDefaultPolicy sets the default action for the directions named in policy. +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 + } + if policy.Input != ActionInvalid { + if err := f.setChainPolicy(ctx, "input", policy.Input); err != nil { + return err + } + } + if policy.Output != ActionInvalid { + if err := f.setChainPolicy(ctx, "output", policy.Output); err != nil { + return err + } + } + if policy.Forward != ActionInvalid { + if err := f.setChainPolicy(ctx, "forward", policy.Forward); err != nil { + return err + } + } + return nil +} + +// nftSetJSON is the subset of a `set` object in `nft -j list set(s)` output we +// decode. +type nftSetJSON struct { + Name string `json:"name"` + Table string `json:"table"` + Type string `json:"type"` + Flags []string `json:"flags"` + Elem []json.RawMessage `json:"elem"` +} + +// nftListJSON is the top-level envelope every `nft -j list ...` command emits: a +// "nftables" array whose items are single-key objects (metainfo, set, rule, ...). +// We only pull the "set" objects out. +type nftListJSON struct { + Nftables []struct { + Set *nftSetJSON `json:"set"` + } `json:"nftables"` +} + +// decodeSets unwraps the `nft -j list set(s)` envelope into its set objects. +func (f *NFT) decodeSets(out []string) []*nftSetJSON { + var env nftListJSON + if err := json.Unmarshal([]byte(strings.Join(out, "\n")), &env); err != nil { + return nil + } + var sets []*nftSetJSON + for _, item := range env.Nftables { + if item.Set != nil { + sets = append(sets, item.Set) + } + } + return sets +} + +// nftSetType maps a set type/family to the nftables set spec that follows +// `nft add set inet
`: a `{ type ... ; [flags interval ;] }` +// expression. It rejects a combination nftables cannot express in an inet set. +func (f *NFT) setSpec(family Family, t SetType) (string, error) { + if family == FamilyAny { + family = IPv4 + } + var addrType string + switch family { + case IPv4: + addrType = "ipv4_addr" + case IPv6: + addrType = "ipv6_addr" + default: + return "", fmt.Errorf("a set requires a concrete ip family: %w", ErrUnsupportedSet) + } + spec := "{ type " + addrType + " ;" + if t == SetHashNet { + spec += " flags interval ;" + } + return spec + " }", nil +} + +// setEntries renders the set's entries as an nftables element expression, e.g. +// `{ 1.2.3.4, 10.0.0.0/8 }`. +func (f *NFT) setEntries(entries []string) string { + return "{ " + strings.Join(entries, ", ") + " }" +} + +// decodeElem decodes one JSON element of an nft set into its string form. A +// scalar becomes the address/CIDR string; a {"prefix":{"addr":..,"len":..}} +// object becomes a CIDR; a {"range":[lo,hi]} object (which an interval set uses +// for a non-CIDR span) becomes "lo-hi"; anything unrecognised is skipped. +func (f *NFT) decodeElem(raw json.RawMessage) string { + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + // nft renders a CIDR element as a prefix object with addr/len fields (an + // interval/hash:net set), not a two-element array — decode it as such so the + // entry is not silently dropped on read. + var prefix struct { + Prefix struct { + Addr string `json:"addr"` + Len json.Number `json:"len"` + } `json:"prefix"` + } + if json.Unmarshal(raw, &prefix) == nil && prefix.Prefix.Addr != "" && prefix.Prefix.Len != "" { + return prefix.Prefix.Addr + "/" + string(prefix.Prefix.Len) + } + // An interval set stores a non-CIDR span as a range object; report it as + // "lo-hi" rather than silently dropping the entry. + var rng struct { + Range []json.RawMessage `json:"range"` + } + if json.Unmarshal(raw, &rng) == nil && len(rng.Range) == 2 { + var lo, hi string + if json.Unmarshal(rng.Range[0], &lo) == nil && json.Unmarshal(rng.Range[1], &hi) == nil { + return lo + "-" + hi + } + } + return "" +} + +// 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 + } + // `nft list sets` accepts a family (inet) but not a table name, so list every + // inet set and keep the ones in our table. It exits 0 with an empty listing + // when there are no sets, so any error here is a genuine failure. + out, err := runCommand(ctx, "nft", "-j", "list", "sets", "inet") + if err != nil { + return nil, err + } + sets := f.decodeSets(out) + result := make([]*AddressSet, 0, len(sets)) + for _, s := range sets { + if s.Table != f.table { + continue + } + detail, err := f.getAddressSet(ctx, s.Name) + if err != nil { + return nil, err + } + if detail == nil { + continue + } + result = append(result, detail) + } + return result, nil +} + +// getAddressSet reads a single nftables set as an AddressSet, or nil if it does +// not exist. +func (f *NFT) getAddressSet(ctx context.Context, name string) (*AddressSet, error) { + out, err := runCommand(ctx, "nft", "-j", "list", "set", "inet", f.table, name) + if err != nil { + // A missing set is a no-op; any other failure (permission denial, nft + // binary trouble, ...) must surface rather than read as "not found". + if strings.Contains(err.Error(), "No such file or directory") { + return nil, nil + } + return nil, err + } + sets := f.decodeSets(out) + if len(sets) == 0 { + return nil, nil + } + detail := sets[0] + set := &AddressSet{Name: name} + switch detail.Type { + case "ipv6_addr": + set.Family = IPv6 + case "ipv4_addr": + set.Family = IPv4 + } + for _, fl := range detail.Flags { + if fl == "interval" { + set.Type = SetHashNet + } + } + for _, raw := range detail.Elem { + if e := f.decodeElem(raw); e != "" { + set.Entries = append(set.Entries, e) + } + } + return set, 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 + } + 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 +} + +// setMatches reports whether an existing set's definition matches a requested +// family/type. AddAddressSet uses this to tell a harmless re-add of an identical, +// already-existing set (safe to treat as success — `nft add set` is idempotent +// for a matching redefinition and does not itself error) apart from a genuine +// type/family conflict (which `nft add set` reports as "File exists" and which +// must surface as an error rather than being silently swallowed). A nil existing +// set (the read races with a concurrent delete, or fails to parse) never matches, +// so the caller treats it as a conflict rather than guessing. +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) 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 + } + spec, err := f.setSpec(set.Family, set.Type) + if err != nil { + return err + } + args := append([]string{"add", "set", "inet", f.table, set.Name}, strings.Fields(spec)...) + if _, err := runCommand(ctx, "nft", args...); err != nil { + if !strings.Contains(err.Error(), "File exists") { + return err + } + // `nft add set` reports "File exists" only on a genuine type/family + // conflict — redefining an identical set succeeds on its own. Read the + // existing set back and only treat this as the harmless case; otherwise + // the conflict is real and must be reported, not swallowed as success. + existing, gerr := f.getAddressSet(ctx, set.Name) + if gerr != nil { + return gerr + } + if !f.setMatches(existing, set.Family, set.Type) { + return fmt.Errorf("address set %q already exists with a different definition: %w", set.Name, err) + } + return nil + } + if len(set.Entries) > 0 { + elem := f.setEntries(set.Entries) + args := append([]string{"add", "element", "inet", f.table, set.Name}, strings.Fields(elem)...) + if _, err := runCommand(ctx, "nft", args...); err != nil { + return err + } + } + return nil +} + +// 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 + } + _, err := runCommand(ctx, "nft", "delete", "set", "inet", f.table, name) + if err != nil && (strings.Contains(err.Error(), "No such file") || strings.Contains(err.Error(), "does not exist")) { + return nil + } + return err +} + +// 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 + } + elem := f.setEntries([]string{entry}) + args := append([]string{"add", "element", "inet", f.table, name}, strings.Fields(elem)...) + _, err := runCommand(ctx, "nft", args...) + return err +} + +// 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 + } + elem := f.setEntries([]string{entry}) + args := append([]string{"delete", "element", "inet", f.table, name}, strings.Fields(elem)...) + _, err := runCommand(ctx, "nft", args...) + return err +} diff --git a/nft_linux_test.go b/nft_linux_test.go new file mode 100644 index 0000000..1857989 --- /dev/null +++ b/nft_linux_test.go @@ -0,0 +1,661 @@ +package firewall + +import ( + "encoding/json" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNFTRules(t *testing.T) { + fw := &NFT{table: "go_firewall"} + + // Marshal a representative rule and confirm the expression. + chain, expr, 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, "input", chain, "expected input chain") + require.Equal(t, "ip saddr 192.168.0.0/24 udp dport 23 counter accept", expr, "unexpected expression") + + // The listing form (as produced by `nft -a`) must parse back to an + // equivalent rule, including the trailing handle. + rule, handle, err := fw.UnmarshalRule("ip saddr 192.168.0.0/24 udp dport 23 accept # handle 4", "input") + require.NoError(t, err) + require.Equal(t, "4", handle, "expected handle 4") + 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 both directions and families. + rules := []*Rule{ + {Family: IPv4, Port: 4789, Proto: UDP, Action: Accept}, + {Direction: DirOutput, Family: IPv6, Port: 4789, Proto: UDP, Action: Accept}, + {Family: IPv4, Source: "67.227.233.116", Port: 4789, Proto: TCP, Action: Accept}, + {Direction: DirOutput, Family: IPv4, Destination: "67.227.233.116", Port: 4791, Proto: TCP, Action: Reject}, + {Family: IPv6, Source: "!2001:db8::1", Action: Drop}, + // A non-address Source/Destination names a set, referenced as @name. + {Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop}, + {Direction: DirOutput, Family: IPv6, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept}, + } + for _, r := range rules { + chain, expr, err := fw.MarshalRule(r) + require.NoError(t, err, "failed to marshal %+v", *r) + + parsed, _, err := fw.UnmarshalRule(expr, chain) + require.NoError(t, err, "failed to parse %q", expr) + require.True(t, parsed.Equal(r, true), + "round-trip mismatch: input %+v, expr %q, output %+v", *r, expr, parsed) + } +} + +// A non-address Source/Destination is emitted as an nftables named-set reference +// (@name), negation included, and parses back to the bare set name. +func TestNFTSetReference(t *testing.T) { + fw := &NFT{table: "go_firewall"} + + _, expr, err := fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop}) + require.NoError(t, err) + require.Contains(t, expr, "ip saddr @blocklist") + + _, expr6, err := fw.MarshalRule(&Rule{Family: IPv6, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept}) + require.NoError(t, err) + require.Contains(t, expr6, "ip6 daddr != @allowlist") + + got, _, err := fw.UnmarshalRule("ip saddr @blocklist tcp dport 22 counter drop # handle 7", "input") + require.NoError(t, err) + require.Equal(t, "blocklist", got.Source) + + gotNeg, _, err := fw.UnmarshalRule("ip6 daddr != @allowlist tcp dport 80 counter accept # handle 8", "input") + require.NoError(t, err) + require.Equal(t, "!allowlist", gotNeg.Destination) + + // A port without a concrete protocol cannot be expressed in nftables. + _, _, err = fw.MarshalRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}) + require.Error(t, err, "expected error marshalling a port with no protocol") +} + +// nft rejects an unqualified `dnat to`/`snat to` in an inet table unless the rule +// already carries a same-family address match, so MarshalNATRule must emit the +// `ip`/`ip6` qualifier the read path (UnmarshalNATRule) already consumes. +func TestNFTNATFamilyQualifier(t *testing.T) { + fw := &NFT{table: "go_firewall"} + + // A plain IPv4 port-forward: no address match, so the qualifier is required. + chain, expr, err := fw.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2"}) + require.NoError(t, err) + require.Equal(t, "prerouting", chain) + require.Contains(t, expr, "dnat ip to 192.168.1.2", "DNAT must carry the ip qualifier: %q", expr) + + // Interface-only IPv4 SNAT. + _, expr, err = fw.MarshalNATRule(&NATRule{Kind: SNAT, Proto: TCP, Interface: "eth0", ToAddress: "1.2.3.4"}) + require.NoError(t, err) + require.Contains(t, expr, "snat ip to 1.2.3.4", "SNAT must carry the ip qualifier: %q", expr) + + // IPv6 DNAT to an address:port emits ip6 and bracketed target. + _, expr, err = fw.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080}) + require.NoError(t, err) + require.Contains(t, expr, "dnat ip6 to [2001:db8::1]:8080", "IPv6 DNAT must carry the ip6 qualifier: %q", expr) + + // Every kind must round-trip back to an equal rule through the list form. + for _, r := range []*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: SNAT, Proto: TCP, Interface: "eth0", ToAddress: "1.2.3.4"}, + {Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080}, + {Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080}, + {Kind: Masquerade, Interface: "eth0"}, + } { + chain, expr, err := fw.MarshalNATRule(r) + require.NoError(t, err, "failed to marshal %+v", *r) + parsed, _, err := fw.UnmarshalNATRule(expr, chain) + require.NoError(t, err, "failed to parse %q", expr) + require.True(t, parsed.EqualBase(r), "round-trip mismatch: input %+v, expr %q, output %+v", *r, expr, parsed) + } +} + +func TestNFTFeatureRules(t *testing.T) { + fw := &NFT{table: "go_firewall"} + + // Confirm a few representative encodings. + cases := []struct { + rule *Rule + want string + }{ + {&Rule{Proto: ICMP, Action: Accept}, "meta l4proto icmp counter accept"}, + {&Rule{Proto: ICMPv6, Action: Accept}, "meta l4proto icmpv6 counter accept"}, + {&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, "icmp type 8 counter accept"}, + {&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept}, "icmpv6 type 135 counter accept"}, + {&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, "tcp dport {80,443} counter accept"}, + {&Rule{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, "udp dport 1000-2000 counter accept"}, + {&Rule{Proto: TCP, Port: 22, State: StateEstablished | StateRelated, Action: Accept}, "tcp dport 22 ct state {established,related} counter accept"}, + {&Rule{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, `iifname "eth0" tcp dport 22 counter accept`}, + {&Rule{Direction: DirOutput, OutInterface: "eth1", Action: Drop}, `oifname "eth1" counter drop`}, + } + for _, c := range cases { + _, expr, err := fw.MarshalRule(c.rule) + require.NoError(t, err, "failed to marshal %+v", *c.rule) + require.Equal(t, c.want, expr, "marshal %+v", *c.rule) + } + + // Round-trip every new-feature rule shape. + rules := []*Rule{ + {Proto: ICMP, Action: Accept}, + {Proto: ICMPv6, Action: Accept}, + {Family: IPv4, Proto: ICMP, Action: Accept}, + {Family: IPv6, Proto: ICMPv6, Action: Drop}, + {Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, + {Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, + {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: StateNew | StateEstablished, Action: Accept}, + {Family: IPv4, Source: "10.0.0.0/8", Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}, + {InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, + {Direction: DirOutput, OutInterface: "eth1", Proto: UDP, Port: 53, Action: Accept}, + // A forward rule may match both an ingress and an egress interface. + {Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Proto: TCP, Port: 22, Action: Accept}, + } + for _, r := range rules { + chain, expr, err := fw.MarshalRule(r) + require.NoError(t, err, "failed to marshal %+v", *r) + + parsed, _, err := fw.UnmarshalRule(expr, chain) + require.NoError(t, err, "failed to parse %q", expr) + require.True(t, parsed.Equal(r, true), + "round-trip mismatch: input %+v, expr %q, output %+v", *r, expr, parsed) + } + + // A forward rule marshals into the forward base chain and matches both interfaces. + chain, expr, err := fw.MarshalRule(&Rule{Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Action: Accept}) + require.NoError(t, err) + require.Equal(t, "forward", chain, "a forward rule targets the forward chain") + require.Equal(t, `iifname "eth0" oifname "eth1" counter accept`, expr) + + // An input interface cannot be matched on an output rule and vice versa. + _, _, err = fw.MarshalRule(&Rule{Direction: DirOutput, InInterface: "eth0", Action: Accept}) + require.Error(t, err, "expected error matching an input interface on an output rule") + _, _, err = fw.MarshalRule(&Rule{OutInterface: "eth0", Action: Accept}) + require.Error(t, err, "expected error matching an output interface on an input rule") +} + +// A listed rule carries `counter packets N bytes M`; the parser must capture the +// counters onto the rule, while they remain outside rule identity. +func TestNFTCounters(t *testing.T) { + f := &NFT{table: "test"} + r, _, err := f.UnmarshalRule("tcp dport 22 counter packets 42 bytes 336 accept # handle 7", "input") + require.NoError(t, err) + require.Equal(t, uint64(42), r.Packets) + require.Equal(t, uint64(336), r.Bytes) + require.Equal(t, Accept, r.Action) + // Counters are not part of rule identity. + require.True(t, r.EqualBase(&Rule{Proto: TCP, Port: 22, Action: Accept}, true), + "counters must not be part of rule identity: %+v", r) + + // A rule without counters parses with zero counters. + r2, _, err := f.UnmarshalRule("tcp dport 22 accept # handle 1", "input") + require.NoError(t, err) + require.Zero(t, r2.Packets) + require.Zero(t, r2.Bytes) +} + +func TestNFTSetSpec(t *testing.T) { + f := new(NFT) + spec, err := f.setSpec(IPv4, SetHashIP) + require.NoError(t, err) + require.Equal(t, "{ type ipv4_addr ; }", spec) + spec, err = f.setSpec(IPv6, SetHashNet) + require.NoError(t, err) + require.Equal(t, "{ type ipv6_addr ; flags interval ; }", spec) + // FamilyAny resolves to IPv4. + spec, err = f.setSpec(FamilyAny, SetHashIP) + require.NoError(t, err) + require.Equal(t, "{ type ipv4_addr ; }", spec) +} + +// collapseSetSpaces must not collapse the spaces inside brace-like content +// that lives inside a quoted comment (it is not a real anonymous set), while +// still collapsing an actual anonymous set match outside any quote. nft's +// quoting has no backslash-escape mechanism: a bare `"` always toggles the +// quoted state. +func TestNFTCollapseSetSpacesQuotedComment(t *testing.T) { + f := new(NFT) + in := `tcp dport { 22, 80 } counter accept comment "a b { c } d" # handle 5` + // The real anonymous set collapses; the quoted comment (braces and spaces + // included) is preserved verbatim. + want := `tcp dport {22,80} counter accept comment "a b { c } d" # handle 5` + require.Equal(t, want, f.collapseSetSpaces(in)) +} + +// MarshalRule must reject a Comment or LogPrefix containing a double quote: nft's +// string literals have no escape for an embedded quote, so such a value cannot be +// written at all, and nft's own parser would otherwise fail with a confusing raw +// syntax error instead of a clear validation error. +func TestNFTMarshalRuleRejectsQuoteInComment(t *testing.T) { + fw := &NFT{table: "go_firewall"} + _, _, err := fw.MarshalRule(&Rule{Action: Accept, Comment: `has "quote"`}) + require.Error(t, err) + + _, _, err = fw.MarshalRule(&Rule{Action: Accept, Log: true, LogPrefix: `has "quote"`}) + require.Error(t, err) + + // A comment/log prefix with no quote is unaffected. + _, expr, err := fw.MarshalRule(&Rule{Action: Accept, Comment: "plain comment"}) + require.NoError(t, err) + require.Contains(t, expr, `comment "plain comment"`) +} + +// unquote must strip a double-quoted value literally rather than decoding it +// as a Go string (strconv.Unquote): nft has no backslash-escape mechanism, so a +// literal backslash sequence in the value (e.g. a Windows-style path) must come +// back unchanged, not be reinterpreted as an escape. +func TestNFTUnquoteNoEscapeDecoding(t *testing.T) { + f := new(NFT) + require.Equal(t, `C:\new`, f.unquote(`"C:\new"`)) + require.Equal(t, "plain", f.unquote(`"plain"`)) + require.Equal(t, "bare", f.unquote("bare")) +} + +// setMatches must treat a matching family/type as safe to swallow as an +// idempotent re-add, and any mismatch (or an unreadable/absent existing set) as +// a genuine conflict that must not be silently treated as success. +func TestNFTSetMatches(t *testing.T) { + f := new(NFT) + require.True(t, f.setMatches(&AddressSet{Family: IPv4, Type: SetHashIP}, IPv4, SetHashIP)) + // FamilyAny resolves to IPv4 for comparison, matching setSpec's own default. + require.True(t, f.setMatches(&AddressSet{Family: IPv4, Type: SetHashIP}, FamilyAny, SetHashIP)) + require.False(t, f.setMatches(&AddressSet{Family: IPv6, Type: SetHashIP}, IPv4, SetHashIP)) + require.False(t, f.setMatches(&AddressSet{Family: IPv4, Type: SetHashNet}, IPv4, SetHashIP)) + require.False(t, f.setMatches(nil, IPv4, SetHashIP)) +} + +// A host address written with an explicit /32 (or /128) prefix must round-trip +// even though nft lists it back without the prefix. Rule identity compares +// addresses semantically (addrEqual/canonAddr), so the bare read-back matches. +func TestNFTHostPrefixRoundTrip(t *testing.T) { + f := &NFT{table: "test"} + orig := &Rule{Family: IPv4, Source: "1.2.3.4/32", Proto: TCP, Port: 22, Action: Accept} + chain, expr, err := f.MarshalRule(orig) + require.NoError(t, err) + // nft strips the /32 from an exact host match when it lists the rule back. + listed := strings.Replace(expr, "1.2.3.4/32", "1.2.3.4", 1) + require.NotEqual(t, expr, listed) + got, _, err := f.UnmarshalRule(listed, chain) + require.NoError(t, err) + require.True(t, got.EqualBase(orig, true), + "a /32 host address must round-trip; got Source %q", got.Source) + + // The same holds for an IPv6 /128 host and its zero-compressed spelling. + orig6 := &Rule{Family: IPv6, Source: "2001:0db8::1/128", Proto: TCP, Port: 22, Action: Accept} + chain6, expr6, err := f.MarshalRule(orig6) + require.NoError(t, err) + listed6 := strings.Replace(expr6, "2001:0db8::1/128", "2001:db8::1", 1) + got6, _, err := f.UnmarshalRule(listed6, chain6) + require.NoError(t, err) + require.True(t, got6.EqualBase(orig6, true), "a /128 host address must round-trip; got %q", got6.Source) +} + +// nft lists ICMPv4 types 15 and 16 by their symbolic names info-request / +// info-reply, which the ICMPv4 name table previously omitted; such a rule must +// still parse back rather than being dropped from GetRules. +func TestNFTICMPv4InfoTypeNameParse(t *testing.T) { + f := &NFT{table: "test"} + for _, tc := range []struct { + num uint8 + name string + }{{15, "info-request"}, {16, "info-reply"}} { + orig := &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](tc.num), Action: Accept} + chain, expr, err := f.MarshalRule(orig) + require.NoError(t, err) + named := strings.Replace(expr, "type "+strconv.Itoa(int(tc.num)), "type "+tc.name, 1) + require.NotEqual(t, expr, named, "marshaled rule should contain the numeric type") + got, _, err := f.UnmarshalRule(named, chain) + require.NoError(t, err) + require.NotNil(t, got.ICMPType) + require.Equal(t, tc.num, *got.ICMPType, "icmp %s is type %d", tc.name, tc.num) + } +} + +// nft lists an ICMPv6 type by its ICMPv6 name, and several of those spellings +// (echo-request, destination-unreachable, ...) mean a different number under +// ICMPv4. The parser must resolve an icmpv6 type through the ICMPv6 table. +func TestNFTICMPv6TypeNameParse(t *testing.T) { + f := &NFT{table: "test"} + + // echo-request is ICMPv6 type 128 (it is 8 under ICMPv4). + chain, expr, err := f.MarshalRule(&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}) + require.NoError(t, err) + named := strings.Replace(expr, "type 128", "type echo-request", 1) + require.NotEqual(t, expr, named, "marshaled rule should contain the numeric type") + got, _, err := f.UnmarshalRule(named, chain) + require.NoError(t, err) + require.NotNil(t, got.ICMPType) + require.Equal(t, uint8(128), *got.ICMPType, "echo-request must resolve to ICMPv6 type 128") + + // nd-neighbor-solicit (135) is a v6-only name absent from the ICMPv4 table; + // it must still parse rather than dropping the rule. + chain2, expr2, err := f.MarshalRule(&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept}) + require.NoError(t, err) + named2 := strings.Replace(expr2, "type 135", "type nd-neighbor-solicit", 1) + got2, _, err := f.UnmarshalRule(named2, chain2) + require.NoError(t, err) + require.NotNil(t, got2.ICMPType) + require.Equal(t, uint8(135), *got2.ICMPType) +} + +// nftables applies and prints a default burst of 5 packets on every limit +// statement, even when none was requested. A rule marshaled with Burst 0 must +// still compare equal to the one nft lists back with burst 5. +func TestNFTRateBurstDefaultNormalized(t *testing.T) { + f := &NFT{table: "test"} + orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}} + chain, expr, err := f.MarshalRule(orig) + require.NoError(t, err) + withBurst := strings.Replace(expr, "rate 10/minute", "rate 10/minute burst 5 packets", 1) + require.NotEqual(t, expr, withBurst) + got, _, err := f.UnmarshalRule(withBurst, chain) + require.NoError(t, err) + require.NotNil(t, got.RateLimit) + require.Equal(t, uint(0), got.RateLimit.Burst, "nft's 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 comment containing a literal '#' must round-trip: strings.Fields would +// otherwise mistake the inner '#' for nft's `# handle N` marker and drop the +// rule. +func TestNFTCommentWithHash(t *testing.T) { + f := &NFT{table: "test"} + orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: "block # 5"} + chain, expr, err := f.MarshalRule(orig) + require.NoError(t, err) + + got, _, err := f.UnmarshalRule(expr, chain) + require.NoError(t, err) + require.Equal(t, "block # 5", got.Comment) + + // Also survive a trailing `# handle N` marker as printed by `nft -a`. + got2, _, err := f.UnmarshalRule(expr+" # handle 2", chain) + require.NoError(t, err) + require.Equal(t, "block # 5", got2.Comment) +} + +// A log prefix containing whitespace must round-trip. nft prints it as a quoted +// string that strings.Fields splits on its internal spaces; the parser has to +// reassemble it rather than reading only the first token (which dropped the rest +// and made the whole rule unparseable, so it vanished from GetRules). +func TestNFTLogPrefixWithSpaces(t *testing.T) { + f := &NFT{table: "test"} + orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, Log: true, LogPrefix: "FW DROP"} + chain, expr, err := f.MarshalRule(orig) + require.NoError(t, err) + got, _, err := f.UnmarshalRule(expr, chain) + require.NoError(t, err) + require.True(t, got.Log) + require.Equal(t, "FW DROP", got.LogPrefix) +} + +// A DNAT rule on SCTP must round-trip through the nft NAT parser, which had no +// sctp case and so dropped the rule on read (re-adding a duplicate every call). +func TestNFTSCTPNATRoundTrip(t *testing.T) { + f := &NFT{table: "test"} + orig := &NATRule{Kind: DNAT, Family: IPv4, Proto: SCTP, Port: 9999, ToAddress: "10.0.0.5", ToPort: 9999} + chain, expr, err := f.MarshalNATRule(orig) + require.NoError(t, err) + got, _, err := f.UnmarshalNATRule(expr, chain) + require.NoError(t, err) + require.True(t, orig.EqualBase(got), "sctp nat rule must round-trip; got %+v", got) +} + +// nft interval sets store a non-CIDR span as a range object; GetAddressSet must +// report it as "lo-hi" rather than silently dropping the entry. +func TestNFTDecodeRangeElem(t *testing.T) { + f := new(NFT) + require.Equal(t, "10.0.0.1-10.0.0.9", f.decodeElem([]byte(`{"range":["10.0.0.1","10.0.0.9"]}`))) + // Real nft renders a CIDR element as a prefix object with addr/len fields. + require.Equal(t, "10.0.0.0/24", f.decodeElem([]byte(`{"prefix":{"addr":"10.0.0.0","len":24}}`))) + require.Equal(t, "192.0.2.1", f.decodeElem([]byte(`"192.0.2.1"`))) +} + +// `nft -a list ruleset` appends `{ # handle N` (and sometimes ` progname ...`) +// to table/chain header lines. headerName must return the bare object name so +// listForeignRules skips our own table; a naive TrimSuffix(line, "{") left the +// handle tail attached, so the skip missed and GetRules re-listed our own rules +// as foreign (duplicates). +func TestNFTHeaderName(t *testing.T) { + f := new(NFT) + require.Equal(t, "inet gofwit", f.headerName("table inet gofwit { # handle 1", "table")) + require.Equal(t, "input", f.headerName("chain input { # handle 1", "chain")) + require.Equal(t, "inet firewalld", + f.headerName("table inet firewalld { # handle 215 progname firewalld", "table")) + require.Equal(t, "ip nat", f.headerName("table ip nat { # handle 6", "table")) + // A header without the -a handle suffix still parses. + require.Equal(t, "inet filter", f.headerName("table inet filter {", "table")) +} + +// A bare FamilyAny redirect/masquerade is emitted into the inet table with no +// family qualifier, so it matches both IPv4 and IPv6 (it is NOT silently +// IPv4-only) and round-trips as FamilyAny. +func TestNFTFamilyAnyNATIsDualStack(t *testing.T) { + f := &NFT{table: "test"} + for _, orig := range []*NATRule{ + {Kind: Redirect, Family: FamilyAny, Proto: TCP, Port: 80, ToPort: 8080}, + {Kind: Masquerade, Family: FamilyAny, Interface: "eth0"}, + } { + chain, expr, err := f.MarshalNATRule(orig) + require.NoError(t, err) + require.NotContains(t, expr, "meta nfproto", "a FamilyAny NAT rule must not pin a family: %q", expr) + got, _, err := f.UnmarshalNATRule(expr, chain) + require.NoError(t, err, "expr %q", expr) + require.Equal(t, FamilyAny, got.Family, "expr %q should read back FamilyAny", expr) + require.True(t, got.EqualBase(orig), "expr %q: want %+v got %+v", expr, orig, got) + } +} + +// nft merges a contiguous/overlapping anonymous port set on read +// ("{22,23,24-30}" -> "{22-30}"). The rule must still compare Equal to its own +// read-back or Sync would remove and re-add it every pass. +func TestNFTPortRangeCoalesceRoundTrip(t *testing.T) { + f := &NFT{table: "test"} + orig := &Rule{Family: IPv4, Proto: TCP, + Ports: []PortRange{{Start: 22, End: 22}, {Start: 23, End: 23}, {Start: 24, End: 30}}, + Action: Accept} + chain, expr, err := f.MarshalRule(orig) + require.NoError(t, err) + require.Contains(t, expr, "22", "marshaled set should carry the ports") + + // Simulate nft's list normalization: the set is merged to a single range and + // re-spelled with the spacing nft prints. + listed := strings.Replace(expr, "{22,23,24-30}", "{ 22-30 }", 1) + require.NotEqual(t, expr, listed, "expected the marshaled set to be re-spelled") + + got, _, err := f.UnmarshalRule(listed, chain) + require.NoError(t, err) + require.True(t, orig.Equal(got, true), + "a coalesced port set must round-trip; got Ports %v", got.Ports) +} + +// nft re-spells a bare `reject` on read as `reject with ` (e.g. +// `reject with icmp port-unreachable`). The parser choked on the trailing `with +// ...` tokens and returned an error, so listChain silently dropped every reject +// rule — it read back as absent and could never be matched or removed. The +// listing form must parse back to the same rule the library rendered. (The other +// nft round-trip tests feed the marshalled form back in, which never carries the +// `with ...` detail, so they missed this.) +func TestNFTRejectRespelledRoundTrip(t *testing.T) { + fw := &NFT{table: "gofwit"} + + rule := &Rule{Family: IPv4, Proto: TCP, Port: 3389, Source: "192.0.2.20/32", Action: Reject} + chain, _, err := fw.MarshalRule(rule) + require.NoError(t, err) + + // The exact form `nft -a list chain` emits for the rule above. + listed := "ip saddr 192.0.2.20 tcp dport 3389 counter packets 0 bytes 0 reject with icmp port-unreachable # handle 2" + parsed, handle, err := fw.UnmarshalRule(listed, chain) + require.NoError(t, err, "re-spelled reject line must parse") + require.Equal(t, "2", handle) + require.Equal(t, Reject, parsed.Action) + require.True(t, rule.Equal(parsed, true), "re-spelled reject must round-trip: got %+v", parsed) + + // A reject carrying a comment after the `with ...` detail must still parse the + // comment (the detail-consuming loop stops at the comment marker). + withComment := `tcp dport 22 counter reject with icmp port-unreachable comment "block ssh" # handle 3` + parsed, _, err = fw.UnmarshalRule(withComment, "input") + require.NoError(t, err) + require.Equal(t, Reject, parsed.Action) + require.Equal(t, "block ssh", parsed.Comment) + + // A tcp-reset reject (two-word detail) must also parse. + tcpReset := "tcp dport 22 counter reject with tcp reset # handle 4" + parsed, _, err = fw.UnmarshalRule(tcpReset, "input") + require.NoError(t, err) + require.Equal(t, Reject, parsed.Action) +} + +// nft renders a set's prefix element as an object {"addr":..,"len":..}, not a +// two-element array. The decoder previously expected an array, so every hash:net +// (interval) set's CIDR entries were silently dropped on read. +func TestNFTDecodePrefixElemObject(t *testing.T) { + f := new(NFT) + cases := map[string]string{ + `{"prefix": {"addr": "10.0.0.0", "len": 8}}`: "10.0.0.0/8", + `{"prefix": {"addr": "192.168.0.0", "len": 16}}`: "192.168.0.0/16", + `{"prefix": {"addr": "2001:db8::", "len": 32}}`: "2001:db8::/32", + `"1.2.3.4"`: "1.2.3.4", + `{"range": ["10.0.0.1", "10.0.0.5"]}`: "10.0.0.1-10.0.0.5", + } + for in, want := range cases { + require.Equal(t, want, f.decodeElem(json.RawMessage(in)), "f.decodeElem(%s)", in) + } +} + +// A rule that names the netfilter default burst (5) must round-trip Equal to +// itself. nft reads the default burst back as 0, so without folding 5==0 the rule +// would churn on every Sync. Regression for eqRateLimit + the nft read path. +func TestNFTBurst5RoundTrip(t *testing.T) { + fw := &NFT{table: "go_firewall"} + in := &Rule{Proto: TCP, Port: 25, Action: Drop, + RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}} + chain, expr, err := fw.MarshalRule(in) + require.NoError(t, err) + got, _, err := fw.UnmarshalRule(expr, chain) + require.NoError(t, err) + require.True(t, in.Equal(got, true), "a Burst=5 rule must round-trip Equal to itself (expr %q)", expr) +} + +// A LogPrefix ending in a single quote is an identity field that real nft stores +// and lists back double-quoted (log prefix "block'"). The old trimQuotes stripped +// leading/trailing ' as well as the surrounding ", corrupting it to "block" and +// making the rule churn on every Sync. Parsing the exact nft list form asserts the +// fix against what nft actually emits, not just MarshalRule's own output. +func TestNFTLogPrefixEdgeQuoteRoundTrip(t *testing.T) { + fw := &NFT{table: "go_firewall"} + in := &Rule{Proto: TCP, Port: 25, Action: Drop, Log: true, LogPrefix: `block'`} + chain, expr, err := fw.MarshalRule(in) + require.NoError(t, err) + require.Contains(t, expr, `log prefix "block'"`, "marshaled form must match nft's list output") + got, _, err := fw.UnmarshalRule(expr, chain) + require.NoError(t, err) + require.Equal(t, in.LogPrefix, got.LogPrefix, "LogPrefix ending in a quote must round-trip (expr %q)", expr) + require.True(t, in.Equal(got, true)) + + // Parse the literal line nft lists (with a handle marker) to cover the read path. + parsed, _, err := fw.UnmarshalRule(`tcp dport 25 log prefix "block'" drop # handle 5`, "input") + require.NoError(t, err) + require.Equal(t, `block'`, parsed.LogPrefix) +} + +// A Comment ending in a single quote must round-trip through nft (same unquote +// fix); real nft lists it as comment "note'". +func TestNFTCommentEdgeQuoteRoundTrip(t *testing.T) { + fw := &NFT{table: "go_firewall"} + in := &Rule{Proto: TCP, Port: 25, Action: Drop, Comment: `note'`} + chain, expr, err := fw.MarshalRule(in) + require.NoError(t, err) + got, _, err := fw.UnmarshalRule(expr, chain) + require.NoError(t, err) + require.Equal(t, in.Comment, got.Comment, "Comment ending in a quote must round-trip (expr %q)", expr) +} + +func TestNFTLogLimitRoundTrip(t *testing.T) { + f := &NFT{table: "test"} + cases := []*Rule{ + {Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Log: true, LogPrefix: "ssh"}, + // A non-default burst (nft's default is 5, which normalizes to the unset 0). + {Family: IPv4, Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 10}}, + {Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}}, + {Family: IPv4, Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept}, + {Family: IPv4, Proto: TCP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, + } + for _, orig := range cases { + chain, expr, err := f.MarshalRule(orig) + require.NoError(t, err) + got, _, err := f.UnmarshalRule(expr, chain) + require.NoError(t, err, "expr %q", expr) + require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got) + } + + // Per-source connection limiting is not expressible in this model. + _, _, err := f.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}) + require.Error(t, err) +} + +func TestNFTNATRoundTrip(t *testing.T) { + f := &NFT{table: "test"} + 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"}, + {Kind: Masquerade, Family: IPv4, Interface: "eth1"}, + } + for _, orig := range cases { + chain, expr, err := f.MarshalNATRule(orig) + require.NoError(t, err) + got, _, err := f.UnmarshalNATRule(expr, chain) + require.NoError(t, err, "expr %q", expr) + require.True(t, got.EqualBase(orig), "expr %q: want %+v got %+v", expr, orig, got) + } +} + +func TestNFTProtocolAndComment(t *testing.T) { + f := &NFT{table: "test"} + cases := []*Rule{ + {Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept}, + {Family: IPv4, Proto: GRE, Action: Accept}, + {Family: IPv6, Proto: ESP, Action: Accept}, + {Family: IPv4, Proto: AH, Action: Drop}, + {Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"}, + {Family: IPv4, Proto: TCP, Port: 443, Action: Accept, Comment: "https"}, + } + for _, orig := range cases { + chain, expr, err := f.MarshalRule(orig) + require.NoError(t, err) + got, _, err := f.UnmarshalRule(expr, chain) + require.NoError(t, err, "expr %q", expr) + require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got) + require.Equal(t, orig.Comment, got.Comment, "expr %q comment", expr) + } +} + +// sanitizeNFTName reduces an arbitrary rule prefix to a valid nftables +// identifier, falling back to the default table name when nothing usable +// remains. This is the name every nft container is created under, so its +// behavior is worth pinning. +func TestSanitizeNFTName(t *testing.T) { + cases := []struct{ in, want string }{ + {"myapp", "myapp"}, + {"my-app.v2", "my_app_v2"}, // '-' and '.' become '_' + {"my app", "my_app"}, // space becomes '_' + {"-lead-trail-", "lead_trail"}, // leading/trailing separators trimmed + {"a/b:c*d", "abcd"}, // unsupported runes dropped + {"", NFTDefaultTable}, // empty prefix -> default table + {"***", NFTDefaultTable}, // all-dropped -> default table + {"__", NFTDefaultTable}, // trims to empty -> default table + } + for _, c := range cases { + require.Equalf(t, c.want, sanitizeNFTName(c.in), "sanitizeNFTName(%q)", c.in) + } +} diff --git a/pf.go b/pf.go new file mode 100644 index 0000000..bff3258 --- /dev/null +++ b/pf.go @@ -0,0 +1,2119 @@ +//go:build darwin || freebsd + +package firewall + +import ( + "bufio" + "context" + "fmt" + "net" + "os" + "strconv" + "strings" +) + +// 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() +} + +const ( + // PFType is the backend type string reported by PF.Type. + PFType = "pf" + // 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. + ensured bool + // natEnsured records the same for the nat/rdr anchor references, added + // lazily only when a NAT rule is first written. + natEnsured bool +} + +// 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 +} + +// 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 +} + +// 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 references our nat and rdr anchors so that +// translation rules loaded into the anchor are evaluated. pf requires +// translation rules (and their anchors) to appear before any filtering +// statement, so the references are inserted at that boundary rather than +// appended. Missing references are added and the main ruleset reloaded. +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 +} + +// 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 +} + +// 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 +} + +// 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() +} + +// 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, +} + +// 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 +} + +// 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, " ") + " }" +} + +// 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 +} + +// 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. +func (f *PF) MarshalRule(r *Rule) (string, error) { + // 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 can only match a port alongside a concrete transport protocol. + if r.PortNeedsConcreteProtocol() { + return "", fmt.Errorf("a port requires a tcp, udp or sctp protocol") + } + if err := r.checkICMPType(); err != nil { + return "", err + } + + // pfctl expands a discrete source-port list into one rule per port on read, so it + // would not round-trip as a single rule; reject it (a contiguous range is one + // token and does round-trip). + if len(r.SourcePortSpecs()) > 1 { + return "", fmt.Errorf("pf cannot express a source-port list as a single rule: %w", ErrUnsupportedSourcePort) + } + // pfctl expands a discrete destination-port list the same way (the PortList + // capability is false), so reject a genuine list of more than one spec. + 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; reject rather than silently drop the label. + 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. + if r.IsOutput() && r.InInterface != "" { + return "", fmt.Errorf("an input interface cannot be matched on an output rule") + } + if !r.IsOutput() && r.OutInterface != "" { + return "", fmt.Errorf("an output interface cannot be matched on an input rule") + } + + 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") + default: + return "", fmt.Errorf("no valid action was provided") + } + + // 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, valid only on stateful pass 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) + } + var opts []string + if r.ConnLimit != nil { + if !r.ConnLimit.PerSource { + return "", fmt.Errorf("pf connection limiting is per-source only: %w", ErrUnsupportedConnLimit) + } + opts = append(opts, fmt.Sprintf("max-src-conn %d", r.ConnLimit.Count)) + } + if r.RateLimit != nil { + // pf's max-src-conn-rate has no burst term, so a requested burst cannot + // be honored. Reject it rather than emit a rule that reads back with + // Burst 0 and fails rule-identity comparison. + if r.RateLimit.Burst != 0 { + return "", fmt.Errorf("pf does not support a rate-limit burst: %w", ErrUnsupported) + } + 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 +} + +// 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 +} + +// 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 +} + +// 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 +} + +// 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 +} + +// 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 +} + +// 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 +} + +// 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. + if strings.HasPrefix(line, "[") { + if len(rules) > 0 { + 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 +} + +// compactRules drops the opaque (nil) placeholder rows parseAnchorRules keeps +// for lines it cannot model, leaving only the rules the library represents. The +// read/merge/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 +} + +// filterAnchors maps each logical (merged) 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. With no opaque rows it +// equals mergedFamilyAnchors. It backs the merged-position insert/move mapping. +func (f *PF) filterAnchors(rules []*Rule) []int { + phys := make([]int, 0, len(rules)) + modeled := make([]*Rule, 0, len(rules)) + for i, r := range rules { + if r == nil { + continue + } + phys = append(phys, i) + modeled = append(modeled, r) + } + anchors := mergedFamilyAnchors(modeled) + for k := range anchors { + anchors[k] = phys[anchors[k]] + } + return anchors +} + +// 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 +} + +// 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 +} + +// 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 +} + +// natAnchors is filterAnchors for NAT rules: it maps each logical (merged) NAT +// rule to its physical row index, skipping opaque (nil) rows. +func (f *PF) natAnchors(rules []*NATRule) []int { + phys := make([]int, 0, len(rules)) + modeled := make([]*NATRule, 0, len(rules)) + for i, r := range rules { + if r == nil { + continue + } + phys = append(phys, i) + modeled = append(modeled, r) + } + anchors := mergedNATFamilyAnchors(modeled) + for k := range anchors { + anchors[k] = phys[anchors[k]] + } + return anchors +} + +// 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 +} + +// 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) + // Merge the library's own IPv4/IPv6 pairs, then number the anchor's rules as one + // ordered list (pf evaluates a single filter list, so its position spans + // directions). Numbering after the merge keeps a collapsed pair from leaving a + // gap. Foreign rules appended below live outside this anchor and keep Number 0. + rules = mergeFamilies(rules) + numberSequential(rules) + // Collapse each input/output twin into one DirAny rule. Numbering first keeps + // the surviving rows' sequential position intact. + rules = mergeDirections(rules) + rules = append(rules, f.listForeignRules(ctx)...) + return rules, 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 +} + +// AddRule adds a rule to the zone. +func (f *PF) AddRule(ctx context.Context, zoneName string, r *Rule) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + // A DirAny rule fans out into an inbound rule plus its role-swapped outbound + // rule; add each as a concrete-direction anchor rule. + if r.Direction == DirAny { + for _, sub := range expandDirections(r) { + if err := f.AddRule(ctx, zoneName, sub); err != nil { + return err + } + } + return nil + } + + line, err := f.MarshalRule(r) + if err != nil { + return err + } + + rules, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + // Skip if an equivalent rule already exists. Equal is family-aware (via + // impliedFamily): pf keeps inet and inet6 filter rules as separate objects and + // echoes a family-agnostic rule back without an af, so an IPv6 rule must not be + // treated as a duplicate of its IPv4 twin. (NAT differs — see RemoveNATRule.) + for _, e := range rules { + if e != nil && e.Equal(r, true) { + return nil + } + } + + // Preserve any translation rules that share the anchor. + _, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + + filterRaw = append(filterRaw, line) + return f.loadAnchor(ctx, natRaw, filterRaw) +} + +// AddRulesBatch appends every rule and reloads the anchor once, rather than one +// pfctl reload per rule. Rules that already exist are skipped. The reload is a +// single atomic pfctl transaction. It implements RuleBatcher. +func (f *PF) AddRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + existing, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + _, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + + for _, top := range rules { + // A DirAny rule fans out into an inbound rule plus its swapped outbound rule. + for _, r := range expandDirections(top) { + line, err := f.MarshalRule(r) + if err != nil { + return err + } + dup := false + for _, e := range existing { + if e != nil && e.Equal(r, true) { + dup = true + break + } + } + if dup { + continue + } + filterRaw = append(filterRaw, line) + existing = append(existing, r) + } + } + return f.loadAnchor(ctx, natRaw, filterRaw) +} + +// ReplaceRulesBatch reloads the anchor so its filter rules are exactly rules, +// preserving any translation (nat/rdr) rules, in one pfctl transaction. It +// implements RuleBatcher. +func (f *PF) ReplaceRulesBatch(ctx context.Context, zoneName string, rules []*Rule) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + _, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + + var filterRaw []string + for _, top := range rules { + // A DirAny rule fans out into an inbound rule plus its swapped outbound rule. + for _, r := range expandDirections(top) { + line, err := f.MarshalRule(r) + if err != nil { + return err + } + filterRaw = append(filterRaw, line) + } + } + return f.loadAnchor(ctx, natRaw, filterRaw) +} + +// 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. +func (f *PF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + // A DirAny rule occupies a row for each direction; insert its inbound row and + // its swapped outbound row, each 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 + } + + line, err := f.MarshalRule(r) + if err != nil { + return err + } + + rules, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + for _, e := range rules { + if e != nil && e.Equal(r, true) { + return nil + } + } + + if position <= 0 { + position = 1 + } + // position is a merged Number: GetRules collapses IPv4/IPv6 pairs and numbers + // the result, while the anchor holds one physical row per rule. Map through the + // merged anchors so a collapsed pair earlier in the list does not skew the + // placement (and does not split a family pair). rules is 1:1 with filterRaw, and + // filterAnchors skips any opaque (unmodeled) row so it does not consume a + // logical position. + idx := mergedInsertIndex(f.filterAnchors(rules), len(filterRaw), position) + filterRaw = append(filterRaw[:idx], append([]string{line}, filterRaw[idx:]...)...) + + _, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + return f.loadAnchor(ctx, natRaw, filterRaw) +} + +// reorderRows returns the anchor's filter rows with every physical row matching r +// relocated to the merged 1-based position, and whether any row moved. A rule read +// back by GetRules can be a collapsed IPv4/IPv6 pair spanning two physical rows, so +// both twin rows are relocated together. rules is 1:1 with filterRaw. The target +// position lives in the merged (post-mergeFamilies) index space, 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, which could + // never match a merged rule the caller read back; 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 := mergedInsertIndex(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 { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + rules, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + + newRaw, moved := f.reorderRows(rules, filterRaw, r, position) + if !moved { + return nil + } + + _, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + return f.loadAnchor(ctx, natRaw, newRaw) +} + +// RemoveRule removes a rule from the zone. +func (f *PF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error { + if err := f.ensureAnchor(ctx); err != nil { + return err + } + + rules, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + + // Rebuild the filter ruleset without the matching rule(s). GetRules collapses an + // IPv4/IPv6 twin into one FamilyAny rule, so removing that read-back rule must + // clear both underlying anchor rows; match with EqualForRemoval so a concrete- + // family target still removes only its own family and never the twin's row. + kept := make([]string, 0, len(filterRaw)) + removed := false + var reAdd *Rule + reAddIdx := -1 + for i, e := range rules { + // An opaque (nil) row is never a match target, so it is always preserved. + if e != nil && e.EqualForRemoval(r, true) { + removed = true + // A concrete-family target that matched a genuine dual-family row (an + // anchor rule with no af, covering both) would drop both families; re-add + // the untargeted family in the dual row's own slot so the surviving family + // keeps both its coverage and its place in the anchor. + if s := splitDualRow(e, r); s != nil { + reAdd = s + reAddIdx = len(kept) + } + continue + } + kept = append(kept, filterRaw[i]) + } + if !removed { + return nil + } + + // Splice the surviving family's marshaled line into the kept rows at the dual + // row's position, so a single reload both removes the dual row and preserves + // ordering. + if reAdd != nil { + line, err := f.MarshalRule(reAdd) + if err != nil { + return err + } + kept = append(kept[:reAddIdx], append([]string{line}, kept[reAddIdx:]...)...) + } + + _, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + return f.loadAnchor(ctx, natRaw, kept) +} + +// 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 +} + +// 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 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 + } + + filterLines := make([]string, 0, len(backup.Rules)) + for _, r := range backup.Rules { + line, err := f.MarshalRule(r) + if err != nil { + return err + } + filterLines = append(filterLines, line) + } + + natLines := make([]string, 0, len(backup.NATRules)) + for _, r := range backup.NATRules { + line, err := f.MarshalNATRule(r) + if err != nil { + return err + } + natLines = append(natLines, line) + } + + return f.loadAnchor(ctx, natLines, filterLines) +} + +// MarshalNATRule encodes a NAT rule as a pf rdr/nat rule line for our anchor. +func (f *PF) MarshalNATRule(r *NATRule) (string, error) { + if err := r.validate(); err != nil { + return "", err + } + // pfctl expands a discrete match-port list into one rule per port on read, so a + // multi-port match would not round-trip as a single NAT rule (mirroring the + // filter-rule guard). A contiguous range is one token and is allowed. + if len(r.PortSpecs()) > 1 { + return "", fmt.Errorf("pf cannot express a NAT match-port list as a single rule: %w", ErrUnsupported) + } + + var parts []string + switch r.Kind { + case DNAT: + parts = append(parts, "rdr") + case SNAT, Masquerade: + parts = append(parts, "nat") + case Redirect: + return "", fmt.Errorf("pf does not support a portless redirect; use dnat to a local address: %w", ErrUnsupportedNAT) + default: + return "", fmt.Errorf("invalid nat kind") + } + + // 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: + parts = append(parts, "->", r.ToAddress) + case Masquerade: + if r.Interface == "" { + return "", fmt.Errorf("pf masquerade requires an interface") + } + parts = append(parts, "->", "("+r.Interface+")") + } + + return strings.Join(parts, " "), nil +} + +// 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++ + + 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": + 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": + 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": + 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. + r.Kind = Masquerade + } 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 +} + +// 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) + // Merge families, then number the anchor's NAT rules as one ordered list so a + // collapsed pair leaves no gap; foreign NAT rules appended below keep Number 0. + rules = mergeNATFamilies(rules) + numberNATSequential(rules) + rules = append(rules, f.listForeignNATRules(ctx)...) + return rules, nil +} + +// AddNATRule adds a NAT rule to the zone. +func (f *PF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := f.ensureNATAnchors(ctx); err != nil { + return err + } + + line, err := f.MarshalNATRule(r) + if err != nil { + return err + } + + rules, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + // Dedup only against a rule that also covers this rule'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. + for _, e := range rules { + if e != nil && e.EqualForDedup(r) { + return nil + } + } + + // Preserve the filter rules that share the anchor. + _, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + + natRaw = append(natRaw, line) + return f.loadAnchor(ctx, natRaw, filterRaw) +} + +// 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 err := f.ensureNATAnchors(ctx); err != nil { + return err + } + + line, err := f.MarshalNATRule(r) + if err != nil { + return err + } + + rules, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + // Dedup only against a rule that also covers this rule's family (EqualForDedup, + // see AddNATRule) so an opposite-family twin is not mistaken for a duplicate. + for _, e := range rules { + if e != nil && e.EqualForDedup(r) { + return nil + } + } + + if position <= 0 { + position = 1 + } + // position is a merged Number: GetNATRules collapses IPv4/IPv6 pairs and numbers + // the result, while the anchor holds one physical row per rule. Map through the + // merged NAT anchors so a collapsed pair earlier in the list does not skew the + // placement (and does not split a family pair). rules is 1:1 with natRaw, and + // natAnchors skips any opaque (unmodeled) row so it does not consume a + // logical position. + idx := mergedInsertIndex(f.natAnchors(rules), len(natRaw), position) + natRaw = append(natRaw[:idx], append([]string{line}, natRaw[idx:]...)...) + + // Preserve the filter rules that share the anchor. + _, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + return f.loadAnchor(ctx, natRaw, filterRaw) +} + +// RemoveNATRule removes a NAT rule from the zone. +func (f *PF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error { + if err := f.ensureNATAnchors(ctx); err != nil { + return err + } + + rules, natRaw, err := f.anchorNATRules(ctx) + if err != nil { + return err + } + + // Rebuild the NAT ruleset without the matching row(s). GetNATRules collapses an + // IPv4/IPv6 twin into one FamilyAny rule (mergeNATFamilies), so removing that + // read-back rule must clear both underlying anchor rows — mirror RemoveRule: + // match with EqualForRemoval so a concrete-family target still removes only its + // own family and never the twin's row. + kept := make([]string, 0, len(natRaw)) + removed := false + for i, e := range rules { + // An opaque (nil) row is never a match target, so it is always preserved. + if e != nil && e.EqualForRemoval(r) { + removed = true + continue + } + kept = append(kept, natRaw[i]) + } + if !removed { + return nil + } + + _, filterRaw, err := f.anchorRules(ctx) + if err != nil { + return err + } + return f.loadAnchor(ctx, kept, filterRaw) +} + +// 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 +} + +// Capabilities returns the set of features the pf backend can express. +func (f *PF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + ICMPv6: true, + // pfctl expands a port list (`port { 80 443 }`) into one rule per port when + // it lists the ruleset, so a discrete multi-port rule does not round-trip as + // a single rule (a range, kept as one token, does). Report PortList as + // unsupported to reflect that; callers open several ports with a rule each. + PortList: false, + ConnState: false, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: true, + DefaultPolicy: false, + RuleCounters: true, + AddressSets: true, + Comments: true, + } +} + +// 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) ----------------------------------------------- + +// tableFamily infers a table's family from its entries (defaulting to IPv4). +func (f *PF) tableFamily(entries []string) Family { + for _, e := range entries { + if strings.Contains(e, ":") { + return IPv6 + } + } + return IPv4 +} + +// 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 { + return nil, nil + } + var result []*AddressSet + for _, line := range out { + name := strings.TrimSpace(line) + if name == "" { + continue + } + set, err := f.getAddressSet(ctx, name) + if err != nil || set == nil { + continue + } + result = append(result, set) + } + return result, nil +} + +// 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") +} + +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 +} + +// 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 + } + if len(set.Entries) == 0 { + // pf does not lazily create tables, so a later filter rule referencing + // would fail to load. `-T replace` with no addresses creates the + // table empty (or empties an existing one) so it exists for the caller. + _, err := runCommand(ctx, "pfctl", "-t", set.Name, "-T", "replace") + return err + } + args := []string{"-t", set.Name, "-T", "add"} + 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 +} diff --git a/pf_test.go b/pf_test.go new file mode 100644 index 0000000..bec570a --- /dev/null +++ b/pf_test.go @@ -0,0 +1,603 @@ +//go:build darwin || freebsd + +package firewall + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPFMissingTableErr verifies the sentinel that makes address-set removal +// idempotent. pfctl prints "Table does not exist" for a missing table, so the +// removal helpers must recognize that exact wording (an earlier "not found" guard +// never matched, so removing an absent set errored instead of no-op succeeding). +func TestPFMissingTableErr(t *testing.T) { + fw := new(PF) + require.True(t, fw.isMissingTableErr(fmt.Errorf("pfctl: Table does not exist.")), + "pfctl's actual missing-table message must be recognized") + require.False(t, fw.isMissingTableErr(fmt.Errorf("pfctl: not found")), + "the wrong sentinel must not match") + require.False(t, fw.isMissingTableErr(nil)) + require.False(t, fw.isMissingTableErr(fmt.Errorf("pfctl: permission denied")), + "a real failure must not be treated as a missing table") +} + +// 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 merged 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: "67.227.233.116", Port: 4789, Proto: TCP, Action: Accept}, + {Direction: DirOutput, Family: IPv4, Destination: "67.227.233.116", 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. + _, err = fw.MarshalRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}) + require.Error(t, err, "expected error marshalling a port with no protocol") + + // 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. + _, err = fw.MarshalRule(&Rule{Proto: TCP, SourcePort: 1024, Action: Accept}) + require.NoError(t, err, "a single source port is valid") + _, err = fw.MarshalRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 2048}}, Action: Accept}) + require.NoError(t, err, "a source-port range is valid") + _, err = fw.MarshalRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}) + require.Error(t, err, "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) + } + + // pf cannot express a discrete destination-port list (PortList is false): + // pfctl expands `port { 80 443 }` into one rule per port on read, so it is + // rejected rather than emitted as a rule that reads back as several. + _, err := fw.MarshalRule(&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}) + require.ErrorIs(t, err, ErrUnsupported, "a destination-port list must be rejected under pf") + + // pf cannot express a connection-state match in this model. + _, err = fw.MarshalRule(&Rule{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}) + require.Error(t, err, "expected error marshalling a state match under pf") + + // Interface/direction mismatches must be rejected. + _, err = fw.MarshalRule(&Rule{Direction: DirOutput, InInterface: "em0", Action: Accept}) + require.Error(t, err, "expected error matching an input interface on an output rule") + _, err = fw.MarshalRule(&Rule{OutInterface: "em0", Action: Accept}) + require.Error(t, err, "expected error matching an output interface on an input rule") + + // pf has no distinct forward chain, so a forward rule is rejected with the + // ErrUnsupportedForward sentinel. + _, err = fw.MarshalRule(&Rule{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept}) + require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected") + require.False(t, fw.Capabilities().Forward, "pf does not advertise forward support") +} + +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. + _, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "x"}) + require.Error(t, err, "expected pf to reject a log prefix") + _, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}) + require.Error(t, err, "expected pf to reject a limit on a non-accept rule") + _, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}) + require.Error(t, err, "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. + _, err = fw.MarshalNATRule(&NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}) + require.Error(t, err, "expected pf to reject a redirect") + _, err = fw.MarshalNATRule(&NATRule{Kind: Masquerade, Family: IPv4}) + require.Error(t, err, "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"} + _, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, + RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}}) + require.ErrorIs(t, err, 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 IPv4/IPv6 filter twin is collapsed by mergeFamilies into one FamilyAny +// rule (impliedFamily FamilyAny). RemoveRule/MoveRule must locate that read-back +// rule with EqualForRemoval, not the family-strict Equal — which never matches +// it, so the port stays open. Regression for the pf remove no-op on a merged rule. +func TestPFMergedFilterTwinIsRemovable(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) + + merged := mergeFamiliesCopy([]*Rule{v4, v6}) + require.Len(t, merged, 1, "the v4/v6 twin must collapse into one rule") + m := merged[0] + require.Equal(t, FamilyAny, m.impliedFamily()) + + // The old family-strict matcher could find neither physical row. + require.False(t, m.Equal(v4, true)) + require.False(t, m.Equal(v6, true)) + + // The new matcher (EqualForRemoval) finds both, so RemoveRule clears both anchor + // rows and MoveRule can locate the rule. + require.True(t, v4.EqualForRemoval(m, true)) + require.True(t, v6.EqualForRemoval(m, true)) +} + +// MoveRule must relocate BOTH physical rows of a merged IPv4/IPv6 pair, not just +// the first. mergeFamilies re-pairs rows on the lower physical index, so moving only +// the first row of the 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 pair as a block. Regression for the pf MoveRule merged-pair defect. +func TestPFReorderRowsMergedPair(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 merges these to A(Number 1), B(Number 2). + 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 merged A (read back as a collapsed FamilyAny pair) to position 2 — after B. + out, moved := fw.reorderRows(rules, raw, mk(FamilyAny, 22), 2) + require.True(t, moved) + require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out, + "both rows of the pair must move together, landing after B") + + // Move merged B up to the front (position 1) — the previously-working direction. + 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. + out, moved = fw.reorderRows(rules, raw, mk(IPv4, 22), 3) + 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) +} + +// 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]) +} + +// pf reports PortList=false because pfctl expands a discrete port list +// (`port { 80 443 }`) into one rule per port on read, so a multi-port rule does +// not round-trip. MarshalRule must reject a destination-port list (as it already +// does for a source-port list and as the sibling PortList=false backends do) +// rather than emit a rule that reads back as several and churns Sync. A single +// contiguous range (PortRange=true) 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.MarshalRule(&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") +} + +// The NAT marshal path has the same constraint: a rdr/nat match-port list expands +// on read, so MarshalNATRule must reject a multi-port match rather than emit a +// list that reads back as several rules. +func TestPFMarshalNATRejectsPortList(t *testing.T) { + f := &PF{anchor: "go_firewall"} + _, err := f.MarshalNATRule(&NATRule{ + Kind: DNAT, + Proto: TCP, + Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, + ToAddress: "10.0.0.5", + }) + require.Error(t, err, "a NAT match-port list must be rejected") + require.ErrorIs(t, err, ErrUnsupported) +} + +// 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]) +} diff --git a/scripts/__pycache__/refactor_order.cpython-314.pyc b/scripts/__pycache__/refactor_order.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7fa50080f72ec1aedc59ffcffe86c93a95537fd0 GIT binary patch literal 11913 zcmb_ieQ*=WmG6;8(&*dY#s=*1$AV?Byx0U5W6T7M{*YG&iwRYbyREuP-9LQn)gD#( zw%vp`tPyF)&RqjIN=|U zhNZEv6c8nEWQPCu#MgOAq_4c6pA5H61SRo;e`<<9?H@lYh621C=7Ujw+8-T1Bg%aA zjL0AHhy0VG#0NuBQ38UZ*JeA41cRYTUW!eLa+i(gw|M#EGZARw2O{I)kR0`gqB0LZ zGrT`a2}Q#Zz6B{pB+);8dP?+op)am7Y;z=$kPD>k!V;Q8EyN;D0KJi=)TBL z1?4Cb-0J0fLo@staVi4cO~>RY?~g=8zr>#wC&EA$#{v@w!6+^Sqi1Lj8plBDgbeG8YbcLP z!AMkYe^ze%k%_UXU?_IcI~k@!Se7;M0coa9iiL23xUZ(+Iz?U2L1W1q}J z?k`OcfeaOY&u>&8qiy_f=_u4x({QO)T0%}RCk!#8m&6SFctS21U@w;mh9Q$ySO3+O z$i{r(Bv4#O2I>eAm_frpBOz8&MN5!( zLn2lmGlX-uA_-QFQp6@$HO^JVC)j+3C_<*_ww4s{2#IDZS}Q_IEPC|-YBjoIAMgY=Zw%*7@=Em7e?4X z*Ty~ME@CwQsLPI4dy*Og!M$QW+yXmP`fU5*Qo&rzU7`OHp`<{u3i}@|*IN6`D_c28 zN5Od2lcJVX_KuM=j9?$C)Y|L6K8sK?RIR^jPp1ky!pA+QMxj5S^*J>PEAF!i?siri zSv{=(0Pux!IPwFO_L!lb#EcO`gbAw8hKM;xJofnFaAXRspB#=!owaBtW;6fl z>8bGe*|9)yJi3|pdc7yXe%bm#@$&QoScSBV*3Ro!K9&?^7CIrwSzV% zB-&6#fw@3^PIWXGPpM!MLgK}!+RGPaZ(l&7hKlO+t|q6hMkF0$`$-eh!;x0}9fxL12nSZZ(ytVHh8yV;i8tY%UU( zBD$@s(nZczq-WJ*08|nvzGr9;yod^O`F3w29^>y&fh#0#Xm9rz6q6hU8>!gn(#W{f z9;0H#*+u&(Doju@;e#wAGaLAXGTeS9JT10QO4HJ$IDR(V-Y1?Hr@|4m-0g?_)1ur9 z_AfZ@r*Ngc2$Qux*B&d*{PK@cK9nXI<#vpUq3rDjG4)T$J>F%NV0c7+6|(3^to@Z zS*qE*P_y~Q@uixMk83*ebuRbj+$E_k@5YjjCqSMI#@COBV^aY>6pr!{za)zQgT|-) z5*=X|uW%s#=f}?bCB;gg5Qiyj2&O_|qafuT2TU^#NU@5c*t7@@MG4K4Vvt0|0;7rq zr=ad@;b2HHPKhCf!(D@mp_nLgRg8h4sF)G0g#wCcQVPc+o-zrgRzg8lSh`qrlhAdO z6$|n>CW#W-1H~{grMQ+&pRCv^)XUGfOH&K^=A8DfnAKHKjKRP~%9tt`c2#*$*-&ii z3l3IcrX@6eGT0oDmlf${2|WkJQM|rV8x(<6C-Pr|Xu@hfbP;Fi?4e7CQqinqU84V9 z)8-o|7MnU3ONSPkI z6IaRXYnNZk)C^}`#}oVio3)rKAC-~LUD?WAAGO}tlbW8}bEj(KN3Ds$Oy#az89!Hg zyR0cOkgKdscc%w3l`V-uAT~R5X(nBlb+;rA+_zNbxRO-gZEj7Hd*E~@_T4LAm;S<1 zdCMhB(wJ*)n`eHs@h5w4+5Yb6V(Z>i*Ie(nC-0PR_;vZOPW)SKlFL=CO%L8)*FN8p zsoI&e=PGNizH;T2^y}HmO-UPYo_=kqZp%X5migwTy6qp=ZO=KX(#^LWb;&~y9Obh| zE*(j~FlYF-C+lcVGWXpj_Z;p_S^KT(?}a%rWoV@WuAfYI3?hGg_>+N7bal z^RR_DDl+!98FuZ{CnF3gJH|YHTt}*UWVFB^tljT4|AH}NzO7_`IlBxqBBldf!9MvF ztY(6Y!mFBw0QId*t06#vXqK*EzX3OZ{kCYWeT+bsTkKJ*_DyfS8UoYQa)cR0L8NKO zr9J7t=*$YmbpSoBOk4Bn8BnOuQb!xG=+6QeN+Tq1B1^IjaPI2m2M=enNY z@uv~u>uvT8|p!PgvcL4mLQ+lowM67ZJ+JA z)RXou**zcIJ@+_!#?^A8F4OW-#<63O>$+!mr(9Wk{gSreoKP1ldu{7 zEdnVa`$klp1)#s!rGic9Gb}4H7P)+M0}a#@GHhNAg9#A9+L~87%W=@KSug;UBg2+e z+d;(ZIqfWh5!(&CqQdWCOJU=KIsp}xR|%xZ!K^2vxY9HYTLg>dZ2C;w^SnjaVY5aj z0A>^D=q=2G8CpQ>Y>Lxc9ASKxh-suB!M}%#clU`=aa_gNf$(%N1fi-z01UBt@I1zs zFg%2i9FR)T58)~v_pet%)h3}{kGJSJC!kXFuTEy!%Hk*}e#Y&B_<0!AbMcxrk(Ijh)_jWOs5;yf}o-H=e_pX;82AX>oRqjad9kL5#nW3&Y@4roRlijD}iHc=oX zqYVJ~Fu&=R{~x?m0zHgkJS)!psq9L-fs*txWFCXm3qOjT0=s9qmiA)JKFDJ5uW@kW z?5&2s4N(pT0ZtnsR;4Nz|hTPN5rTC zs+aRH3UDZ{`|~GCItp#hVB<7oPZQ)pSp`5G%zjf^oiyc~WvQ;)&bp)lpLX7M)+Y`3 z?aoUbxjN7F@U`%b)AJ44x~=o)Zr8n(97r239evJ+w~s!uld?6RIf$bg^@(HKBDeis zc~$E5#g>;B%X<@t@An>lM^5crZ0^48-j(qQi32%%b;jP1VH;Fsk>3o!Cn2b}VJopu zeh;PSkU><&5Hq}p@go42=Ngx*iL-_QK)Ly-5X6L_HXAdvV{Iru*Eb3(M@^djKW!KQ z4Mkg+BCHSmWq~Pd!FIX@3;YbRT5J6mBS@oq4di!4g1Av32H*hu+)?o6p)VK@#BiWJ zVu$5pfq0a`XDZ%45zEN&W$&DztBV2TRWJ_~@Q==hV9{_CsMpUyUsl1o8YU{j8Mcw{ zy>x+)e8dgx;mCJ)0KxowGdPso5c(4$%swV^6)p;$Noz)|75I@tDEbs@t{=e|2 zui{QRGZw&c*45%pnj}Ch%MP)fRU%7Tjn%N3zP1IaQH{~WA2mla>Ks8F2NXR@p03C8W!0KQj3 zmuxU@7(E>?8}+V;{kBk`0v}GV{NS+x+yM=S>RqaaOfie{xF3964gnAZwxv8M@&~{< zRXun!oFYJAK|*8haZrsX4I+UJ)EoJ2i#O|NziCd#Qrl)v zCQsfpC;Bsv_PZ~%W*wV8IFUa7_0F$-?((Z2oWSBu4=QUCgZC}&do^pX*Zx)Qob3_SC*y_4=!CUU~CtUs|eeU#M=M=a#B>d|bWb?ixN2(&oT2@TSm~F-X#!~7y!{)6uWHgyEkL_gKpUH28+I3s1Lm1hDc0Sv#c_^@ zoiGEo{0u<7h;7&**otisj9*_5TS0f33cAZuc(Q>OGsm0rT1*Wlp|6HIOA^n;;J`MJ zcM_>Wh&$+MYyQAAUZ#7B*a~%9yzQAI+`LCB^V30Drst(pPeSD`ei`~hS)pKc5gxit zh%JWAXyXD@fBQ%imKdq56iRe?9ySaW?*MhGp~bT+s!6vdfv^g=a?G%)7#Zj*H_Wak z$NK)MM<=HsSNV?Nr?>`kPq8bwp=OyLxJn10j&uyO^_U%p%wthCuMxDXgr241M14FK z4XY7X#YBB}#Y7J)6*K(_$T)Qn0LDbMtt4X8s{HZ0j>&2DAHg?y7%~9lIq*iez0-5o zQHD=%F4~*n@m_gN`i1MBYo58jA2$8qg>3DMcgnXU_uh4srkdV6^vFmmIv#M&S;r+u z>i8m8i!m+RC0lCi9d3;Z?adV2QUy=Pr?7zZWs0*UKXmLVQE|3Y^QhH9*^8u0Is)}{ zvm}k+>t@KnNmu1aF{@^YP8gjlX&fsBFvIanVDO2IF4P9y9KDQ5wMdry^hncCh15mwf`2>?O){fJ#e@n!s;w}`$S^jXDnx`c|uAOisM#gmo$BKedt4>QwBTPOx;apy%({*WEnY8soeeav4gmGK7HIx9Gx|CiRXKI^KXMe^i4G$=MRKG+7hZdxh=pR0g(AESBR;G6#e5U+z{ zxQ&9|wlQu2yc3qXcyKORR7Zmz7Pbo99*F`@nvr4WMd31uq_E<}U=%&jX?aqHMJF57 zZ3D2HP%-^ic#t7_0YkaRT9cmC_`B`jGk$3Mz;>J6bdR;Xb>J%plE=O}p!!EpuaNTo z5qYu!hWCeJtl+D_C7SKX0i*~_)oM{!pTTGJvHHY3rj--fCffJS0t_X2shfGeHu9co?l*mz~?ji zDu$Z0Z~CvVvYH6ilEP&$jncCIZP078-bQ;W%m(OXm<{cfz#WSUUzIN7zG}EBf@pxI z)z`+iMziEY9<8SSTkQEl`&Rvn_M~+l06u}20{cT=t%m*!JGI7FyE-4fyJo^^4b@Bh3hx9l?6vj__#hlT53GkL@Y>dhViZxHWU!R<6D41#T{aeG@^A>19uV@Z0253Xj#yJ=f6+0pzxI7E%l^41c7 zn}M;ZXct~z(;9mq;0ULn^n-K(8FRL^6Op<~Dk=F_8_xSv=Xt+&{ zxQPKz+>*cOg=gn*^=&LF#l(1xcGU~{q`U)WrGN;SF~j9WoFvK%G^ba(pb6YFQ*U>v z_W)HA0Ak~=5L`ctjh~^E;GCou-gUpeaO#2mC`MqEo_8#RySU9d;r~eoWR0XAjKy(6 zgJPw=Wii@|Bw+$Dns;jO-sR_vK?cH~xIG5*u~5a}eUUowYT%YvA&#|sQiRJK5vfP1 z#_NhY5-fc9FCZdUK=wOeAk)zQvyu7!8|Dw&zSH)PBTr=(K76IJnn7C$=i7q{a{;0P zc;78l_uddJro_nGkHmIK=@${zS1(qrXHujyDYUs z2hxj}ZNUtMRKE!19-5OE?N1Ht$eQH%%?_!&79ct&`eC0w~l`KXlh@k;ZWk} zB0G>{%~+7*Dsv_4bIyT>7GkRSl&~iALtDObj;qEGCi=k$A2^N5!MUl-j^jw@M4nEn zxCjo1c3s-__HOl@NIRWsMtBcLP#@cy5u=9E$FIL~?G-p%ceE^Wo=*TevRAEFtZ(mL zvez%z>$CO-I8>@`n(NC}dluLVIB~a^(qJ;JUbSuhe736R%SRK3b7fUaWsM7EjZ0M=*o`-CZ`_#}_@rXfjlLfb-W<$U>;%BU87vjn_6VpzviI z`m?qDx841T0}t%(*{<2HWY;|axM$LPZgcf_IoBsFm#mnrxm1(dn`PH5v2_b>Me_02jV!mws3Xk%1r8P#@2X;ZKCK+ z;l|)(FBlpd^OPu-u`#$xH8v*wIjo^7oHVv}8DGy~hL&Ej>^m{Icg)wX$|g!=k;jAX zget=*3`m~3aAS0*(k@kj;ku{80l3rLBYhL c!Zbe=7{WS|@kL|JU6%XmKJXRTTFT1*0`(UgjsO4v literal 0 HcmV?d00001 diff --git a/scripts/refactor_backend.py b/scripts/refactor_backend.py new file mode 100644 index 0000000..007e6d5 --- /dev/null +++ b/scripts/refactor_backend.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +"""refactor_backend.py — normalize function placement/naming in a go-firewall backend. + +The library's convention (see CLAUDE.md and the APF refactor it was modeled on): + + * 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. +FUNC_RE = re.compile(r"^func\s+(?:\((?:(\w+)\s+)?\*?(\w+)\)\s+)?([A-Za-z_]\w*)\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 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("--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), "hidx": hidx}) + names = {d["name"] for d in decls} + + # 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 / (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 + if d["name"] == "New" + typ or d["name"] in shared: + 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, or a method with an anonymous receiver. + # 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} | anon_methods + | {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: + why = "constructor" if old == "New" + typ else ("shared" if old in shared else "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}") + print(f"# {len(rename_map)} change(s); {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) + 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): + 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 + block_recv = m.group(1) + 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..6f7e6f1 --- /dev/null +++ b/scripts/refactor_order.py @@ -0,0 +1,245 @@ +#!/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 + +# Matches a function declaration together with its immediately preceding +# column-0 `//` doc comments. Groups: +# 3: receiver variable (None for a plain function) +# 4: receiver type (None for a plain function) +# 5: function name +FUNC_RE = re.compile( + r"(?m)^((?://.*\n)*)^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\(" +) + + +def split_blocks(text): + """Split source into (preamble, [block_dict, ...]). + + Each block begins with the doc comments immediately preceding a `func` line + and ends at the start of the next block, so comments stay attached to their + function. + """ + lines = text.split("\n") + func_lines = [i for i, line in enumerate(lines) if line.startswith("func ")] + if not func_lines: + return text, [] + + decl_re = re.compile(r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\(") + + def comment_start(func_line): + i = func_line - 1 + # Allow a single blank line between the previous block and the doc comment. + if i >= 0 and lines[i].strip() == "": + i -= 1 + while i >= 0 and lines[i].startswith("//"): + i -= 1 + return i + 1 + + comment_starts = [comment_start(fl) for fl in func_lines] + first_start = comment_starts[0] + preamble_lines = lines[:first_start] + preamble = "\n".join(preamble_lines) + if preamble_lines: + preamble += "\n" + + blocks = [] + for idx, fl in enumerate(func_lines): + end_line = func_lines[idx + 1] if idx + 1 < len(func_lines) else len(lines) + block_lines = lines[comment_starts[idx] : end_line] + block_text = "\n".join(block_lines) + if block_text and not block_text.endswith("\n"): + block_text += "\n" + + m = decl_re.match(lines[fl]) + if not m: + die(f"could not parse declaration: {lines[fl]}") + assert m + blocks.append( + { + "recv_var": m.group(1), + "recv_type": m.group(2), + "name": m.group(3), + "text": block_text, + } + ) + + 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): + """Return a map from function name to the set of in-file functions it calls.""" + names = {block["name"] for block in blocks} + graph = {name: set() for name in names} + + method_call_re = None + if receiver: + method_call_re = re.compile(rf"\b{re.escape(receiver)}\.([A-Za-z_]\w*)\(") + + for block in blocks: + name = block["name"] + text = block["text"] + deps = graph[name] + + # Receiver method calls: f.name(...) + if method_call_re: + for mm in method_call_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) + + 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/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..f60b528 --- /dev/null +++ b/test/integration/guest-linux-run.sh @@ -0,0 +1,438 @@ +#!/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 + apt-get install -y --no-install-recommends iptables iptables-persistent netfilter-persistent ipset + ;; + 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) + 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/ufw_linux.go b/ufw_linux.go new file mode 100644 index 0000000..399bc39 --- /dev/null +++ b/ufw_linux.go @@ -0,0 +1,1588 @@ +package firewall + +import ( + "bufio" + "context" + "encoding/hex" + "fmt" + "net" + "os" + "strconv" + "strings" + + dbus "github.com/coreos/go-systemd/dbus" +) + +const ( + // UFWType identifies the ufw backend. + UFWType = "ufw" + // 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" + // The iptables rules files hold 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 +} + +// 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 + + // Connect to systemd dbus interface. + conn, err := dbus.NewWithContext(ctx) + if err != nil { + return nil, fmt.Errorf("failed to connect to systemd: %s", err) + } + defer conn.Close() + + // Find the systemd service for ufw and confirm it was loaded. + prop, err := conn.GetUnitPropertyContext(ctx, "ufw.service", "UnitFileState") + if err != nil { + return nil, fmt.Errorf("error getting ufw service property: %s", err) + } + if prop.Value.Value() != "enabled" { + 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") + } + + // 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) + } + } + + // Return the new ufw object. + return ufw, nil +} + +// Type returns the backend identifier for ufw. +func (f *UFW) Type() string { + return UFWType +} + +// GetZone reports no zone; ufw has no zone support. +func (f *UFW) GetZone(ctx context.Context, iface string) (zoneName string, err error) { + return "", nil +} + +// 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) +} + +// 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 never occurs in a real ufw file (ufw always writes both dapp +// and sapp, using "-" for whichever is absent) and is rejected as malformed. +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 route rule binding both interfaces adds a second trailing interface token, + // so it alone may reach eight tokens; every other tuple with eight is malformed. + if n < 6 || n > 9 || (n == 8 && !forward) { + 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 + r.RateLimit = &RateLimit{Rate: 12, Unit: PerMinute, Burst: 6} + default: + return nil, fmt.Errorf("unsupported action: %s", tokens[0]) + } + + // The trailing token(s) after the six core fields carry the direction and any + // interface binding. An ordinary rule has one such token (`in`, `out`, or an + // interface-bound `in_eth0`/`out_eth0`); a route rule binding both interfaces + // has two (`in_eth0 out_eth1`). 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 single token fixes the in/out direction and its interface. + var dirToks []string + switch n { + case 7: + dirToks = tokens[6:7] + case 8: + dirToks = tokens[6:8] + case 9: + dirToks = tokens[8:9] + } + for _, tok := range dirToks { + name, iface, hasIface := strings.Cut(tok, "_") + 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", tok) + } + } + + // Verify the protocol value is valid. + r.Proto = GetProtocol(tokens[1]) + if r.Proto == ProtocolAny && !strings.EqualFold(tokens[1], "any") { + 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 + } + return +} + +// MarshalRule encodes a firewall rule into a ufw rulespec. +func (f *UFW) MarshalRule(r *Rule) (string, error) { + // Features this backend cannot express in its rule model are rejected up + // front rather than silently dropped. ufw's tuple carries tcp, udp, esp, ah + // and gre; ICMP/ICMPv6 and SCTP go through before.rules (needsIPTablesRules). + if f.protoNeedsRaw(r.Proto) { + return "", fmt.Errorf("ufw does not express the %s protocol in a tuple", r.Proto) + } + if r.State != 0 { + return "", fmt.Errorf("ufw does not support connection-state matching") + } + // Connection limits, and any rate limit other than ufw's built-in `limit`, + // live in the before.rules files, not a user.rules tuple — callers route them + // via needsIPTablesRules. Reaching MarshalRule with one means it would be + // silently dropped, so reject it rather than lose it. + if r.ConnLimit != nil { + return "", fmt.Errorf("ufw does not express a connection limit in a tuple") + } + if r.RateLimit != nil && !f.isNativeLimit(r) { + return "", fmt.Errorf("ufw expresses only its built-in rate limit (`limit`) in a tuple") + } + // ufw logs a matched rule with its `log`/`log-all` keyword, but always with its + // own built-in log prefixes — it cannot set a custom LogPrefix. A rule that + // needs a custom prefix is routed to before.rules by needsIPTablesRules; + // reaching here with one means it would be silently dropped, so reject it. + if r.Log && r.LogPrefix != "" { + return "", fmt.Errorf("ufw cannot set a custom log prefix in a tuple") + } + // ufw needs a concrete tcp/udp protocol to match multiple ports (a list or a + // range); a single port may be matched across any protocol. + if r.HasPortSet() && r.Proto != TCP && r.Proto != UDP { + return "", fmt.Errorf("ufw requires tcp or udp with multiple ports") + } + // ufw can match a source port across any protocol for a single port, but (like + // a multiport destination) needs a concrete tcp/udp protocol for a list or + // range. A single source port with an unspecified protocol is fine. + if r.HasSourcePortSet() && r.Proto != TCP && r.Proto != UDP { + return "", fmt.Errorf("ufw requires tcp or udp for a source-port list or range") + } + + // A negated address (plain or ipset) has no ufw tuple form, so AddRule diverts + // it to before.rules (needsIPTablesRules) and never reaches here with one. + + // An input rule binds only an in-interface, an output rule only an + // out-interface; a forward (route) rule may bind either or both. + 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") + } + + // Work on a copy as we infer the family and normalize the destination + // below, and we do not want to mutate the caller's rule. + ruleCopy := *r + r = &ruleCopy + + // 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 custom LogPrefix was rejected above. + if r.Log { + parts = append(parts, "log") + } + + // If family is not defined, but a source or destination address is, find out the family. + if r.Family == FamilyAny { + addr := r.Source + if r.Destination != "" { + addr = r.Destination + } + if addr != "" { + netIP, _, err := net.ParseCIDR(addr) + ip := net.ParseIP(addr) + if err != nil && ip == nil { + return "", fmt.Errorf("bad IP format") + } + r.Family = IPv4 + if err == nil { + // Address parsed as a CIDR, use the network IP to determine family. + if netIP.To4() == nil { + r.Family = IPv6 + } + } else if ip.To4() == nil { + // Address parsed as a plain IP, use it to determine family. + r.Family = IPv6 + } + } + } + + // Ensure the destination for family-specific rules + // has a zero IP address to allow using `to/from` definition. + if r.Family != FamilyAny && r.Destination == "" { + r.Destination = f.zeroNet(r.Family) + } + + // 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(r.Family) + } + dstAddr := r.Destination + if r.HasPorts() && dstAddr == "" && srcAddr != "" { + dstAddr = f.anyAddr(r.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(r.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(r.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. + if r.Proto != ProtocolAny && (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. + if r.HasPorts() && dstAddr == "" && srcAddr == "" { + val := iptMultiportValue(r.PortSpecs()) + if r.Proto == ProtocolAny { + 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, " "), nil +} + +// 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 (e.g. a route/forward rule) + // 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 +} + +// 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 +} + +// ParseIPTablesRules parses a ufw before/after rules file, which is in +// iptables-restore format using ufw's own chains. Each `-A <chain> ...` line on +// an input/output/forward chain is reparsed with the iptables rulespec parser; +// lines whose match or action this model cannot represent are skipped. +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() }() + + scanner := bufio.NewScanner(fd) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || line[0] == '#' || line[0] == '*' || line[0] == ':' || line == "COMMIT" { + continue + } + + fields := strings.Fields(line) + if len(fields) < 3 || (fields[0] != "-A" && fields[0] != "--append") { + continue + } + dir, ok := f.ipTablesChain(fields[1]) + if !ok { + continue + } + + // Rewrite the ufw chain to its INPUT/OUTPUT/FORWARD equivalent and reuse the + // iptables parser. + spec := "-A " + iptChainForDirection(dir) + " " + strings.Join(fields[2:], " ") + rule, perr := unmarshalIPTablesRule(spec, family) + if perr != 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, rule.Comment) + rule.Comment = text + rule.HasPrefix = hasPrefix + rules = append(rules, rule) + } + if err := scanner.Err(); err != nil { + return nil, err + } + // A logged rule is a LOG line followed by its action line; fold the pair + // back into one logical rule. + return coalesceLoggedRules(rules), nil +} + +// GetRules returns the current filter rules for the zone. +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. + iptablesFiles := []struct { + path string + family Family + }{ + {UFWBefore, IPv4}, + {UFWBefore6, IPv6}, + } + for _, ff := range iptablesFiles { + iptablesRules, ferr := f.ParseIPTablesRules(ff.path, ff.family) + if ferr != nil { + return nil, ferr + } + rules = append(rules, iptablesRules...) + } + + // Merge rules across families, then renumber the ufw-list rules so a collapsed + // v4/v6 pair leaves no gap. Only the numbered tuple rules (Number != 0) are + // resequenced; before.rules entries kept Number 0 and stay outside the list. + rules = mergeFamilies(rules) + n := 0 + for _, r := range rules { + if r.Number != 0 { + n++ + r.Number = n + } + } + // Collapse each input/output twin into one DirAny rule after numbering, so the + // surviving tuple rows keep their list position. + rules = mergeDirections(rules) + return +} + +// 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 +} + +// 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 == RateLimit{Rate: 12, Unit: PerMinute, Burst: 6} +} + +// 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 +} + +// 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} + 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 +} + +// 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} + } +} + +// 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 iptables rule this model 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 + } + parsed, err := unmarshalIPTablesRule("-A "+iptChainForDirection(dir)+" "+strings.Join(fields[2:], " "), family) + if err != nil { + return nil, false + } + return parsed, true +} + +// writeIPTablesRulesFile atomically replaces a before.rules file with out, +// preserving the existing file's mode and ownership. +func (f *UFW) writeIPTablesRulesFile(path string, out []string) error { + if err := writeConfigFile(path, []byte(strings.Join(out, "\n")), 0640); err != nil { + return fmt.Errorf("failed to move new firewall rules into place: %s", err) + } + return nil +} + +// editIPTablesRulesFile inserts (or removes) a rule's line(s) in a before.rules +// file, just before its COMMIT. A logged rule occupies a LOG line plus an action +// line, coalesced on read and dropped together on remove. It returns whether the +// file was changed. +func (f *UFW) editIPTablesRulesFile(path string, r *Rule, family Family, remove bool) (bool, error) { + data, err := os.ReadFile(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 + } + lines := strings.Split(string(data), "\n") + + if remove { + // Scan with a one-line lookback so a LOG line and its action partner are + // removed together. + out := make([]string, 0, len(lines)) + removed := false + var pendingRaw string + var pendingRule *Rule + flush := func() { + if pendingRule != nil { + out = append(out, pendingRaw) + pendingRule = nil + } + } + for _, raw := range lines { + rule, ok := f.parseIPTablesLine(strings.TrimSpace(raw), family) + if !ok { + flush() + out = append(out, raw) + continue + } + if rule.Action == ActionInvalid && rule.Log { + flush() + pendingRaw = raw + pendingRule = rule + continue + } + logical := rule + merged := pendingRule != nil && iptSameMatch(pendingRule, rule) + if merged { + m := *rule + m.Log = true + m.LogPrefix = pendingRule.LogPrefix + logical = &m + } + if !removed && logical.EqualBase(r, true) { + removed = true + // Only drop the held LOG line when it was this rule's own LOG half + // (merged into logical). An unmerged pending line is a separate, + // foreign rule that must survive removal of this one, so flush it. + if !merged { + flush() + } + pendingRule = nil + continue + } + flush() + out = append(out, raw) + } + flush() + if !removed { + return false, nil + } + return true, f.writeIPTablesRulesFile(path, out) + } + + // Add: skip if the logical rule already exists. + var perLine []*Rule + for _, line := range lines { + if rule, ok := f.parseIPTablesLine(strings.TrimSpace(line), family); ok { + perLine = append(perLine, rule) + } + } + for _, e := range coalesceLoggedRules(perLine) { + if e.EqualBase(r, true) { + return false, nil + } + } + + specs, err := f.marshalIPTablesLines(r, family) + if err != nil { + return false, err + } + commitIdx := -1 + for i, line := range lines { + if strings.TrimSpace(line) == "COMMIT" { + commitIdx = i + break + } + } + if commitIdx == -1 { + return false, fmt.Errorf("no COMMIT line found in %s", path) + } + out := make([]string, 0, len(lines)+len(specs)) + out = append(out, lines[:commitIdx]...) + out = append(out, specs...) + out = append(out, lines[commitIdx:]...) + return true, f.writeIPTablesRulesFile(path, out) +} + +// 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 +} + +// AddRule adds a filter rule to the zone. +// 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 +} + +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 + } + + // 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. + if f.needsIPTablesRules(r) { + return f.editIPTablesRules(r, false) + } + + rule, err := f.MarshalRule(r) + if err != nil { + return err + } + args := f.ruleArgs(r, []string{"prepend"}, rule) + // Attach a comment: a user-supplied Comment takes precedence, otherwise the + // rule is tagged with our prefix so it can be identified as ours. 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 +} + +// 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) +} + +// nativeInsertPosition maps a 1-based merged position (a rule's Number, as +// GetRules reports it) to the 1-based position ufw's own numbered list uses for +// `ufw insert`. GetRules merges IPv4/IPv6 tuple pairs, but ufw numbers every IPv4 +// tuple then every IPv6 tuple without merging, so the two index spaces diverge +// once a dual-family rule and a single-family rule coexist. The pre-merge tuple +// order (IPv4 user.rules then IPv6 user6.rules) is exactly ufw's native order, so +// the merged position's anchor 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 appends instead. +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 +} + +// nativeInsertPositionFromRows maps a 1-based merged 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 (a +// route/forward rule); it still occupies a physical slot, so a route rule preceding +// the anchor shifts the native position instead of being ignored — which would +// place the rule one slot too early per preceding route rule and, for a first-match +// firewall, change enforcement. With no un-representable rows this reduces to the +// plain merged index (position within the representable, merge-collapsed list). +func (f *UFW) nativeInsertPositionFromRows(rows []*Rule, position int) int { + var tuples []*Rule + var physPos []int + for i, r := range rows { + if r != nil { + tuples = append(tuples, r) + physPos = append(physPos, i+1) + } + } + repIdx := mergedInsertIndex(mergedFamilyAnchors(tuples), len(tuples), position) + if repIdx >= len(tuples) { + // 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[repIdx] +} + +// 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 + } + + if f.needsIPTablesRules(r) { + return f.editIPTablesRules(r, false) + } + if position <= 0 { + position = 1 + } + + rule, err := f.MarshalRule(r) + if err != nil { + return err + } + + // position is a merged Number: GetRules collapses IPv4/IPv6 tuple pairs and + // numbers the result, while `ufw insert` counts ufw's own numbered list, which + // lists every IPv4 tuple then every IPv6 tuple without merging. Map the merged + // position to that native position so a dual-family rule earlier in the list + // does not skew the insert (splitting a pair) when single-family rules coexist. + 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 +} + +// 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 { + rule, err := f.MarshalRule(r) + if err != nil { + return err + } + args := f.ruleArgs(r, nil, rule) + if c := f.commentFor(r); c != "" { + args = append(args, "comment", c) + } + _, err = runCommand(ctx, "ufw", args...) + return err +} + +// 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) +} + +// 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 + } + + if f.needsIPTablesRules(r) { + return f.editIPTablesRules(r, true) + } + + rule, err := f.MarshalRule(r) + if err != nil { + return err + } + args := f.ruleArgs(r, []string{"delete"}, rule) + if _, err = runCommand(ctx, "ufw", args...); err != nil { + // Removing an already-absent rule is a no-op, matching every other backend. + // ufw reports the miss as "Could not delete non-existent rule". + if strings.Contains(strings.ToLower(err.Error()), "could not delete") { + return nil + } + return err + } + return nil +} + +// 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 +} + +// 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 +} + +// 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. +func (f *UFW) Reload(ctx context.Context) error { + if f.iptablesRulesChanged { + if _, err := runCommand(ctx, "ufw", "reload"); err != nil { + return err + } + f.iptablesRulesChanged = false + } + return nil +} + +// Close releases any resources held by the backend. +func (f *UFW) Close(ctx context.Context) error { + return nil +} + +// Capabilities reports the features this backend supports. +func (f *UFW) Capabilities() Capabilities { + return Capabilities{ + Output: true, + Forward: true, + ICMPv6: true, + PortList: true, + ConnState: true, + InterfaceMatch: true, + Logging: true, + RateLimit: true, + ConnLimit: true, + NAT: true, + RuleOrdering: true, + DefaultPolicy: true, + RuleCounters: false, + AddressSets: true, + Comments: true, + } +} + +// --- default policy --------------------------------------------------------- + +// 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 +} + +// 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 + } +} + +// 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) +} + +// 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 +} + +// 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 "" +} + +// GetDefaultPolicy returns the default filter policy for each direction. +func (f *UFW) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) { + return f.readPolicy() +} + +// 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 a plain iptables backend (system default paths) that carries +// ufw's rule prefix, used to reach the ipset-backed address-set implementation. +func (f *UFW) setHelper() *IPTables { + return &IPTables{rulePrefix: f.rulePrefix} +} + +// 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) +} diff --git a/ufw_linux_test.go b/ufw_linux_test.go new file mode 100644 index 0000000..9beb404 --- /dev/null +++ b/ufw_linux_test.go @@ -0,0 +1,628 @@ +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 merged 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, err := fw.MarshalRule(rule) + require.NoError(t, err) + 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", ""}, + {`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, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Source: "192.168.0.0/24", SourcePort: 1234, Port: 22, Action: Accept}) + require.NoError(t, err) + 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 with an unspecified protocol is valid (ufw emits + // `proto all ... --sport`); only a source-port list or range needs tcp/udp. + anySrc, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: ProtocolAny, SourcePort: 1234, Action: Accept}) + require.NoError(t, err, "a single source port with proto any must be accepted") + require.Contains(t, anySrc, "port 1234") + _, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: ProtocolAny, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}) + require.Error(t, err, "expected a source-port range without tcp/udp to be rejected") + + // 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 67.227.233.116 in`, + `allow tcp 4791 67.227.233.116 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 + _, err = fw.MarshalRule(orig) + require.NoError(t, err) + 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, err := fw.MarshalRule(&Rule{InInterface: "eth0", Family: IPv4, Proto: TCP, Port: 22, Action: Accept}) + require.NoError(t, err) + require.Equal(t, "allow in on eth0 proto tcp to 0.0.0.0/0 port 22", spec, "unexpected interface marshal") + + // Features ufw cannot express in this model are rejected. A multiport match + // without a concrete tcp/udp protocol is also rejected. + unsupported := []*Rule{ + {Proto: ICMP, Action: Accept}, + {Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}, + {Proto: ProtocolAny, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, + } + for _, r := range unsupported { + _, err := fw.MarshalRule(r) + require.Error(t, err, "expected error marshalling unsupported rule %+v", *r) + } + + // 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 { + 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) + } + + // 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") + spec, err := fw.MarshalRule(logged) + require.NoError(t, err) + require.Equal(t, "allow in log proto tcp to 0.0.0.0/0 port 22", spec) + + // The keyword follows the interface clause. + ifLogged := &Rule{Family: IPv4, Proto: TCP, Port: 22, InInterface: "eth0", Log: true, Action: Accept} + spec, err = fw.MarshalRule(ifLogged) + require.NoError(t, err) + require.Equal(t, "allow in on eth0 log proto tcp to 0.0.0.0/0 port 22", spec) + + // A custom log prefix cannot be set in a tuple: routed raw, rejected by marshal. + 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") + _, err = fw.MarshalRule(prefixed) + require.Error(t, err) +} + +// ufw carries the portless IP protocols gre/esp/ah natively (its +// supported_protocols list), so they must marshal to a `proto` tuple rather than +// being diverted to before.rules where they could not be removed. +func TestUFWPortlessProtocols(t *testing.T) { + fw := new(UFW) + for _, p := range []Protocol{GRE, ESP, AH} { + require.False(t, fw.protoNeedsRaw(p), "%s is native to ufw", p) + // With an address. + spec, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: p, Source: "10.0.0.0/24", Action: Accept}) + require.NoError(t, err, "failed to marshal %s rule", p) + require.Contains(t, spec, "proto "+p.String()) + // Without an address: still emits the proto clause (against an any dest). + spec, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: p, Action: Accept}) + require.NoError(t, err, "failed to marshal bare %s rule", p) + require.Contains(t, spec, "proto "+p.String(), "bare %s rule dropped its protocol: %q", p, spec) + } + // ICMP and SCTP genuinely need the raw path. + require.True(t, fw.protoNeedsRaw(ICMP)) + require.True(t, fw.protoNeedsRaw(ICMPv6)) + require.True(t, fw.protoNeedsRaw(SCTP)) +} + +// TestUFWPortlessTCPUDP guards a bare tcp/udp match ("allow all TCP inbound"): +// with no port and no address, the protocol must still be carried on an `any` +// destination rather than dropped to a bare `allow in` that ufw rejects. +func TestUFWPortlessTCPUDP(t *testing.T) { + fw := new(UFW) + for _, p := range []Protocol{TCP, UDP} { + spec, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: p, Action: Accept}) + require.NoError(t, err, "failed to marshal bare %s rule", p) + require.Contains(t, spec, "proto "+p.String(), "bare %s rule dropped its protocol: %q", p, spec) + require.NotEqual(t, "allow in", spec, "bare %s rule emitted the invalid bare form", p) + } + // A family-agnostic bare tcp match must cover both families via the literal any. + spec, err := fw.MarshalRule(&Rule{Proto: TCP, Action: Accept}) + require.NoError(t, err) + require.Contains(t, spec, "proto tcp", "got %q", spec) + require.Contains(t, spec, "to any", "got %q", spec) + + // A true match-all rule must still be a valid command, not a bare `allow in`. + spec, err = fw.MarshalRule(&Rule{Action: Accept}) + require.NoError(t, err) + require.NotEqual(t, "allow in", spec, "match-all rule emitted the invalid bare form") + require.Contains(t, spec, "to any", "got %q", spec) +} + +// 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, err := fw.MarshalRule(r) + require.NoError(t, err) + 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, err := fw.MarshalRule(&Rule{Proto: TCP, Port: 22, Action: Accept}) + require.NoError(t, err) + 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") +} + +// ufw's tuple grammar has no address negation, so a negated plain source or +// destination is routed to the before.rules raw path (which expresses it as +// `iptables ! -s/-d`) rather than rejected. A non-negated address stays on the +// tuple/CLI path and marshals cleanly. +func TestUFWNegatedAddressRoutesToRaw(t *testing.T) { + fw := new(UFW) + + require.True(t, fw.needsIPTablesRules(&Rule{Family: IPv4, Proto: TCP, Source: "!10.0.0.1", Port: 22, Action: Accept}), + "a negated source must route to before.rules") + require.True(t, fw.needsIPTablesRules(&Rule{Family: IPv4, Proto: TCP, Destination: "!10.0.0.1", Port: 22, Action: Accept}), + "a negated destination must route to before.rules") + + // A non-negated address stays on the tuple path and marshals cleanly. + nonNeg := &Rule{Family: IPv4, Proto: TCP, Source: "10.0.0.1", Port: 22, Action: Accept} + require.False(t, fw.needsIPTablesRules(nonNeg), "a plain address stays on the CLI path") + _, err := fw.MarshalRule(nonNeg) + require.NoError(t, err) +} + +// A native ufw `limit` rule reads back from user.rules as an accept carrying +// ufw's built-in rate. It must be routed through the CLI/user.rules path (not the +// before.rules raw path), or it can never be removed and Restore duplicates it. +func TestUFWNativeLimitRouting(t *testing.T) { + fw := new(UFW) + r, err := fw.UnmarshalRule("limit tcp 22 0.0.0.0/0 any 0.0.0.0/0 in", IPv4) + require.NoError(t, err) + require.True(t, fw.isNativeLimit(r), "a limit tuple must be recognized as a native limit") + require.False(t, fw.needsIPTablesRules(r), "a native limit lives in user.rules, not before.rules") + + spec, err := fw.MarshalRule(r) + require.NoError(t, err) + require.True(t, strings.HasPrefix(spec, "limit "), "a native limit must marshal to a `limit` tuple; got %q", spec) + + // A general (non-native) rate limit still needs the raw before.rules path, and + // MarshalRule must refuse it rather than silently drop the rate. + general := &Rule{Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 100, Unit: PerSecond}} + require.True(t, fw.needsIPTablesRules(general), "a non-native rate limit needs before.rules") + _, err = fw.MarshalRule(general) + require.Error(t, err, "a non-native rate limit must not be silently dropped by MarshalRule") +} + +// MarshalRule must reject a per-rule modifier it cannot put in a tuple (logging, +// a connection limit) rather than silently dropping it — those are routed to the +// before.rules files by needsIPTablesRules. +func TestUFWMarshalRejectsUnexpressibleModifiers(t *testing.T) { + fw := new(UFW) + _, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, Log: true, LogPrefix: "x"}) + require.Error(t, err, "logging must not be silently dropped") + _, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, ConnLimit: &ConnLimit{Count: 10}}) + require.Error(t, err, "a connection limit must not be silently dropped") +} + +// 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, err := fw.MarshalRule(&Rule{Proto: TCP, SourcePort: 1024, Action: Accept}) + require.NoError(t, err) + 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") +} + +// The ufw default policy lives in /etc/default/ufw under DEFAULT_*_POLICY keys +// with quoted ACCEPT/DROP/REJECT values (not ufw.conf's *_ACCEPT yes/no). +func TestUFWPolicyKeyAndValue(t *testing.T) { + fw := new(UFW) + require.Equal(t, "DEFAULT_INPUT_POLICY", fw.policyKey(DirInput)) + require.Equal(t, "DEFAULT_OUTPUT_POLICY", fw.policyKey(DirOutput)) + require.Equal(t, "DEFAULT_FORWARD_POLICY", fw.policyKey(DirForward)) + require.Equal(t, `"ACCEPT"`, fw.policyValue(Accept)) + require.Equal(t, `"DROP"`, fw.policyValue(Drop)) + require.Equal(t, `"REJECT"`, fw.policyValue(Reject), "ufw supports a reject default policy") +} + +// 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 (limit + log) rather than erroring. + out, err := f.MarshalRule(r) + require.NoError(t, err) + 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") +} + +// 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") +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..52bf43f --- /dev/null +++ b/utils.go @@ -0,0 +1,166 @@ +package firewall + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" +) + +func trimQuotes(s string) string { + return strings.Trim(s, "\"'") +} + +// 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. 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() }() + 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 + } + k, v, found := strings.Cut(line, "=") + if !found { + continue + } + if strings.TrimSpace(k) == key { + return trimQuotes(strings.TrimSpace(v)), nil + } + } + return "", scanner.Err() +} + +// 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) { + cmd := exec.CommandContext(ctx, command, 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 { + stderr := strings.TrimSpace(stderrData.String()) + if stderr != "" { + err = fmt.Errorf("%s (stdout %s)", stderr, strings.Join(out, "\n")) + } + // 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..3331e19 --- /dev/null +++ b/wf_windows.go @@ -0,0 +1,906 @@ +package firewall + +import ( + "context" + "fmt" + "net" + "strings" + + wapi "github.com/iamacarpet/go-win64api" + "go4.org/netipx" +) + +const ( + // WFType is the backend type string reported by WF.Type. + WFType = "windows-firewall" + // 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 private-network firewall manager" +) + +// 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 +} + +// 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 faithfully. Decode the first entry so the + // rule still surfaces on read instead of being silently dropped; the remaining + // addresses are not represented (a documented limitation of this backend). + if i := strings.IndexByte(addr, ','); i >= 0 { + return f.decodeAddress(strings.TrimSpace(addr[:i])) + } + + // 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() + } + + // Returned the parsed address. + return + } + + // Handle 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 + } + + // 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. + // Our rule uses source/destination where as 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+" ") +} + +// 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) + } + + // Collapse each inbound rule and its outbound twin into one DirAny rule. WFP + // stores an inbound and an outbound filter as separate objects; the local/remote + // swap the marshal path applies means a both-directions allow reads back as an + // input (source) rule plus an output (destination) rule, which merge here. + rules = mergeDirections(rules) + + return rules, nil +} + +// 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 (matching an +// AddRule that stored the rule under the all-profiles default). A rule matches only +// when its Profiles exactly equals this single 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) 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 +// profileFilter result: every rule matches when useFilter is false, otherwise +// only a rule scoped to exactly filterProfile (see profileFilter on why exact). +func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool { + if !useFilter { + return true + } + return rulesProfiles == filterProfile +} + +// MarshallFWRule encodes a Rule as a Windows FWRule for the given zone. +func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) { + // The Windows Firewall rule model has only inbound and outbound directions; + // forwarded (routed) traffic is handled out of band (RRAS/portproxy), so a + // forward rule cannot be expressed here. + if r.IsForward() { + return nil, unsupportedForward("windows firewall") + } + // Windows Filtering Platform cannot match a port without a concrete + // protocol; dropping the port would silently widen the rule to match all + // traffic, so reject it instead. + if r.PortNeedsConcreteProtocol() { + return nil, fmt.Errorf("a port requires a tcp, udp or sctp protocol") + } + // 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 nil, 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 nil, fmt.Errorf("windows firewall does not support connection-state matching in this model: %w", ErrUnsupportedState) + } + if r.InInterface != "" || r.OutInterface != "" { + return nil, 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. Reject it up + // front so callers use Drop instead. + if r.Action == Reject { + return nil, 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 up front 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 nil, 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. + // Reject it rather than widen. + if r.Family != FamilyAny && !r.Proto.IsICMP() && + familyOfAddr(r.Source) == FamilyAny && familyOfAddr(r.Destination) == FamilyAny { + return nil, fmt.Errorf("windows firewall cannot scope a rule to an IP family without an address; use family any or add an address: %w", ErrUnsupported) + } + if err := r.checkICMPType(); err != nil { + return nil, err + } + + // 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 +} + +// AddRule adds a rule to the zone. +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 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 + } + + // 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 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) + + // Delete every matching rule. 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. + for _, fr := range fwRules { + // 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 + } + + if r.EqualBase(rule, true) { + ok, err := wapi.FirewallRuleDelete(fr.Name) + if err != nil { + return fmt.Errorf("failed to delete rule %q: %w", fr.Name, err) + } + if !ok { + return fmt.Errorf("failed to delete rule %q: reported failure", fr.Name) + } + } + } + + return nil +} + +// 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 +} + +// Capabilities returns the set of features the Windows firewall backend can express. +func (f *WF) Capabilities() Capabilities { + return Capabilities{ + Output: true, + ICMPv6: true, + PortList: true, + ConnState: false, + InterfaceMatch: false, + Logging: false, + RateLimit: false, + ConnLimit: false, + NAT: false, + RuleOrdering: false, + DefaultPolicy: false, + RuleCounters: false, + AddressSets: false, + Comments: true, + } +} + +// 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 the current filter rules managed by this backend. +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 +} + +// 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()) +} + +// 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()) +} diff --git a/wf_windows_test.go b/wf_windows_test.go new file mode 100644 index 0000000..d0fafdb --- /dev/null +++ b/wf_windows_test.go @@ -0,0 +1,296 @@ +package firewall + +import ( + "context" + "testing" + + wapi "github.com/iamacarpet/go-win64api" + "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) + } + + // Features the WFP model cannot express are rejected. + unsupported := []*Rule{ + {Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}, + {InInterface: "Ethernet", Proto: TCP, Port: 22, Action: Accept}, + } + for _, r := range unsupported { + _, err := fw.MarshallFWRule("", r) + require.Error(t, err, "expected error marshalling unsupported rule %+v", *r) + } +} + +// TestWFFamilyScopeRejected verifies that a rule scoped to a single IP family +// but carrying no family-determining match (no address, non-ICMP protocol) is +// rejected rather than silently applied to both families. WFP has no per-rule +// family selector, so honoring such a Family is impossible; the rule would widen +// to both stacks and read back as FamilyAny, churning on every Sync. +func TestWFFamilyScopeRejected(t *testing.T) { + fw := &WF{rulePrefix: "test"} + + // Unrepresentable: an explicit family with nothing to carry it. + for _, r := range []*Rule{ + {Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, + {Family: IPv6, Proto: TCP, Port: 22, Action: Accept}, + {Family: IPv6, Proto: UDP, Action: Accept}, + } { + _, err := fw.MarshallFWRule("", r) + require.Error(t, err, "expected a family-scope rejection for %+v", *r) + } + + // Representable: FamilyAny (no scope), an address carries the family, or ICMP + // implies it. These round-trip. + for _, r := range []*Rule{ + {Proto: TCP, Port: 22, Action: Accept}, + {Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept}, + {Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept}, + {Family: IPv4, Proto: ICMP, Action: Accept}, + } { + fr, err := fw.MarshallFWRule("", r) + require.NoError(t, err, "expected %+v to marshal", *r) + parsed := fw.UnmarshallFWRule(*fr) + require.NotNil(t, parsed) + 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) + } + + // SCTP carries ports in the model, but WFP cannot match ports on it. + _, err := fw.MarshallFWRule("", &Rule{Proto: SCTP, Port: 9000, Action: Accept}) + require.Error(t, err, "WFP should reject a port on SCTP") + + // A portless SCTP rule is fine. + fr, err := fw.MarshallFWRule("", &Rule{Proto: SCTP, Action: Accept}) + require.NoError(t, err) + parsed := fw.UnmarshallFWRule(*fr) + require.NotNil(t, parsed) + require.Equal(t, SCTP, parsed.Proto) + + // Windows Firewall has only inbound/outbound directions, so a forward rule is + // rejected with the ErrUnsupportedForward sentinel. + _, err = fw.MarshallFWRule("", &Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept}) + require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected") + require.False(t, fw.Capabilities().Forward, "windows firewall does not advertise forward support") +} + +// A named ICMPv6 type must resolve through the ICMPv6 table, not the ICMPv4 one: +// several names (e.g. echo-request) map to different numbers per family. Windows +// stores types numerically so this path is dormant in practice, but the decode +// must still be family-correct. +func TestWFICMPv6NamedType(t *testing.T) { + fw := &WF{rulePrefix: "test"} + fr := wapi.FWRule{ + Protocol: wapi.NET_FW_IP_PROTOCOL_ICMPv6, + ICMPTypesAndCodes: "echo-request:*", + Action: wapi.NET_FW_ACTION_ALLOW, + Direction: wapi.NET_FW_RULE_DIR_IN, + } + r := fw.UnmarshallFWRule(fr) + require.NotNil(t, r) + require.NotNil(t, r.ICMPType) + require.Equal(t, uint8(128), *r.ICMPType, "ICMPv6 echo-request is type 128, not the ICMPv4 value 8") +} + +func TestWFUnsupportedLogLimitAndNAT(t *testing.T) { + ctx := context.Background() + fw := &WF{rulePrefix: "test"} + + require.Error(t, fw.AddRule(ctx, "", &Rule{Port: 22, Proto: TCP, Action: Accept, Log: true}), + "WFP should reject logging") + require.Error(t, fw.AddRule(ctx, "", &Rule{Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}), + "WFP should reject rate limiting") + + nat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "1.2.3.4", ToPort: 8080} + require.Error(t, fw.AddNATRule(ctx, "", nat), "WFP should reject NAT") + require.Error(t, fw.RemoveNATRule(ctx, "", nat), "WFP should reject NAT") + _, err := fw.GetNATRules(ctx, "") + require.Error(t, err, "WFP should reject NAT listing") +} + +// decodeAddress must surface a Windows rule that carries a comma-separated +// address list (built-in rules commonly do) by decoding its first entry, rather +// than erroring and letting UnmarshallFWRule drop the whole rule. +func TestWFDecodeAddressMultiValue(t *testing.T) { + fw := &WF{rulePrefix: "test"} + got, err := fw.decodeAddress("192.168.1.1,10.0.0.1") + require.NoError(t, err, "a comma-separated list must not be rejected") + require.Equal(t, "192.168.1.1", got, "the first address represents the rule") + + got, err = fw.decodeAddress("2001:db8::1, 2001:db8::2") + require.NoError(t, err) + require.Equal(t, "2001:db8::1", got) +} + +// 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") +} + +// UnmarshallFWRule must drop a rule scoped by an attribute the Rule model cannot +// represent — an application path, a Windows service, or a specific interface-type +// category — rather than decode it into a bare, unscoped rule. Windows ships many +// program- and service-scoped built-in rules; surfacing one as a match-all rule +// would let it collide (via EqualBase) with a genuinely bare rule and be reconciled +// or removed as if identical. A rule carrying none of those scopes still decodes. +func TestWFUnmarshallDropsUnrepresentableScope(t *testing.T) { + fw := &WF{rulePrefix: "test"} + + base := wapi.FWRule{ + Protocol: wapi.NET_FW_IP_PROTOCOL_TCP, + Action: wapi.NET_FW_ACTION_ALLOW, + Direction: wapi.NET_FW_RULE_DIR_IN, + } + + // Scoped by an unrepresentable attribute: dropped from the view. + dropped := []wapi.FWRule{ + func() wapi.FWRule { r := base; r.ApplicationName = `C:\prog.exe`; return r }(), + func() wapi.FWRule { r := base; r.ServiceName = "Spooler"; return r }(), + func() wapi.FWRule { r := base; r.InterfaceTypes = "Wireless"; return r }(), + func() wapi.FWRule { r := base; r.InterfaceTypes = "Lan, Wireless"; return r }(), + } + for _, fr := range dropped { + require.Nil(t, fw.UnmarshallFWRule(fr), + "a rule scoped by an unrepresentable attribute must be dropped: %+v", fr) + } + + // No unrepresentable scope (interface-type "All" is the all-interfaces default): + // the bare rule still decodes. + for _, fr := range []wapi.FWRule{ + base, + func() wapi.FWRule { r := base; r.InterfaceTypes = "All"; return r }(), + } { + parsed := fw.UnmarshallFWRule(fr) + require.NotNil(t, parsed, "a rule with no unrepresentable scope must decode: %+v", fr) + require.Equal(t, TCP, parsed.Proto) + require.Equal(t, Accept, parsed.Action) + } +} + +// profileMatches must scope a single-zone query/removal to exactly that zone's +// profile bit — never to an all-profiles rule or a foreign multi-profile rule — +// so a zone-scoped GetRules/RemoveRule/Sync can never affect another zone. Only an +// unfiltered ("" zone) query is universal. +func TestWFProfileMatchesExcludesAllProfilesRule(t *testing.T) { + fw := new(WF) + privateFilter, ok := fw.profileFilter("private") + require.True(t, ok) + publicFilter, ok := fw.profileFilter("public") + require.True(t, ok) + + cases := []struct { + name string + rulesProfiles int32 + filterProfile int32 + useFilter bool + want bool + }{ + { + name: "exact zone match", + rulesProfiles: privateFilter, + filterProfile: privateFilter, + useFilter: true, + want: true, + }, + { + name: "different zone does not match", + rulesProfiles: publicFilter, + filterProfile: privateFilter, + useFilter: true, + want: false, + }, + { + name: "an all-profiles rule must not match a single-zone filter", + rulesProfiles: wapi.NET_FW_PROFILE2_ALL, + filterProfile: privateFilter, + useFilter: true, + want: false, + }, + { + name: "a foreign rule spanning more than one profile must not match a single-zone filter", + rulesProfiles: privateFilter | publicFilter, + filterProfile: privateFilter, + useFilter: true, + want: false, + }, + { + name: "an unfiltered (all-zones) query matches an all-profiles rule", + rulesProfiles: wapi.NET_FW_PROFILE2_ALL, + filterProfile: 0, + useFilter: false, + want: true, + }, + { + name: "an unfiltered (all-zones) query matches a single-profile rule too", + rulesProfiles: privateFilter, + filterProfile: 0, + useFilter: false, + want: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := fw.profileMatches(c.rulesProfiles, c.filterProfile, c.useFilter) + require.Equal(t, c.want, got) + }) + } +}