first commit

This commit is contained in:
James Coleman 2026-07-08 15:54:48 -05:00
commit 98aa68f7ed
63 changed files with 33761 additions and 0 deletions

11
.gitignore vendored Normal file
View file

@ -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

31
.golangci.yml Normal file
View file

@ -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

19
LICENSE Normal file
View file

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

91
Makefile Normal file
View file

@ -0,0 +1,91 @@
# go-firewall test targets.
#
# make lint golangci-lint across every target GOOS
# make test-general unit / parser tests — fast, no root, no VM
# make test-integration Linux 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.*

308
README.md Normal file
View file

@ -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
platformagnostic `Rule` struct and the module translates them to whatever
backend is actually running on the host.
Reference documentation: <https://pkg.go.dev/github.com/grmrgecko/firewall>
```go
import "github.com/grmrgecko/firewall"
```
## Supported backends
| Platform | Backends |
| -------- | --------------------------------------------------------------- |
| Linux | firewalld → ufw → CSF → APF → iptables → nftables |
| macOS | pf (Packet Filter) |
| FreeBSD | pf (Packet Filter) |
| Windows | Windows Filtering Platform (WFP) |
## Usage
```go
package main
import (
"context"
"log"
"github.com/grmrgecko/firewall"
)
func main() {
ctx := context.Background()
// Detect and connect to the host's firewall. The rule prefix tags/namespaces
// rules this module creates.
mgr, err := firewall.NewManager(ctx, "myapp")
if err != nil {
log.Fatal(err)
}
defer mgr.Close(ctx)
// Resolve the zone for an interface (empty for backends without zones).
zone, err := mgr.GetZone(ctx, "eth0")
if err != nil {
log.Fatal(err)
}
// Allow inbound TCP 443 from a subnet, logged and rate-limited.
rule := &firewall.Rule{
Family: firewall.IPv4,
Source: "192.168.0.0/24",
Port: 443,
Proto: firewall.TCP,
Action: firewall.Accept,
Log: true,
LogPrefix: "https",
RateLimit: &firewall.RateLimit{Rate: 20, Unit: firewall.PerSecond, Burst: 10},
}
if err := mgr.AddRule(ctx, zone, rule); err != nil {
log.Fatal(err)
}
// Forward inbound TCP 8080 to an internal host (a NAT rule).
nat := &firewall.NATRule{
Kind: firewall.DNAT,
Family: firewall.IPv4,
Proto: firewall.TCP,
Port: 8080,
ToAddress: "10.0.0.5",
ToPort: 80,
}
if err := mgr.AddNATRule(ctx, zone, nat); err != nil {
log.Fatal(err)
}
// Some backends stage changes; Reload activates them (a no-op where
// changes apply immediately).
if err := mgr.Reload(ctx); err != nil {
log.Fatal(err)
}
}
```
## CLI
`cmd/go-firewall` is a unified firewall management CLI and implementation demo
for the library. It auto-detects the host's active backend and exposes the same
surface across all of them. Build it from the repo root:
```sh
make cli # builds ./build/go-firewall
make install # installs into $GOBIN
```
Managing rules needs appropriate privileges (root/Administrator), and the CLI
never modifies the host unless you run a mutating subcommand.
```sh
go-firewall status # backend + capabilities
go-firewall rule list # all filter rules (PREFIX column flags ours)
go-firewall rule add --proto tcp --port 443 --source 192.168.0.0/24 --log
go-firewall rule add --proto tcp --ports 80,443,1000-2000 --comment "web"
go-firewall rule remove --proto tcp --port 443
go-firewall rule insert 1 --proto tcp --port 22 # 1-based position
go-firewall nat add --kind dnat --proto tcp --port 8080 --to-address 10.0.0.5 --to-port 80
go-firewall nat insert 1 --kind dnat --proto tcp --port 8080 --to-address 10.0.0.5 --to-port 80
go-firewall nat add --kind masquerade
go-firewall policy get
go-firewall policy set --input drop --forward drop
go-firewall set create blocklist --family ipv4 --type hash:net
go-firewall set add-entry blocklist 203.0.113.0/24
go-firewall set show blocklist # metadata + every entry
go-firewall backup -o snapshot.json # portable JSON snapshot (rules, NAT, policy, sets)
go-firewall restore -f snapshot.json # replay the snapshot
go-firewall zone eth0
go-firewall reload
go-firewall install-completions # bash/zsh/fish completion
```
Global flags: `--prefix` (rule namespace; default `go_firewall`), `--no-reload`
(skip the automatic reload after a mutation), `-j/--json` (machine-readable
output — list, status, and a `{"status":...}` object on mutations), `--version`.
A rule's flags are identical across `add`,
`remove`, `insert` and `move`, so the flag set that creates a rule is also its
match key for removal. Run `go-firewall <command> --help` for the full flag
reference.
## The `Rule` type
| Field | Meaning |
| -------------- | -------------------------------------------------------------------------------- |
| `Direction` | `DirInput` (default), `DirOutput`, `DirForward`, or `DirAny` — the input, output, forward (routing) chain, or both input and output. See `Capabilities().Forward` and the `DirAny` note below. |
| `Priority` | Rule priority, where the backend supports it (e.g. firewalld rich rules). |
| `Family` | `FamilyAny`, `IPv4`, or `IPv6`. |
| `Source` | Source address/CIDR. Prefix with `!` to negate, where supported. |
| `Destination` | Destination address/CIDR. Prefix with `!` to negate, where supported. |
| `Port` | Single destination port. A non-zero port requires a 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.

2196
apf_linux.go Normal file

File diff suppressed because it is too large Load diff

819
apf_linux_test.go Normal file
View file

@ -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")
}

275
backup.go Normal file
View file

@ -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)
}

120
capabilities_linux_test.go Normal file
View file

@ -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)
}

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

@ -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")
}

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

@ -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)
}
}

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

@ -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

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

@ -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=

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

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

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

@ -0,0 +1,166 @@
// Command go-firewall is a unified firewall management CLI built on the
// github.com/grmrgecko/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)
}

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

@ -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")
}

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

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

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

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

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

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

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

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

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

@ -0,0 +1,189 @@
package main
import (
"context"
"fmt"
"os"
fw "github.com/grmrgecko/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")
}

118
configfile.go Normal file
View file

@ -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()
}

11
configfile_other.go Normal file
View file

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

157
configfile_test.go Normal file
View file

@ -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()
}

18
configfile_unix.go Normal file
View file

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

1947
csf_linux.go Normal file

File diff suppressed because it is too large Load diff

888
csf_linux_test.go Normal file
View file

@ -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=<n>` to `--icmp-type <n>` for an icmp rule, just like `d=`).
// A foreign rule that carries the type in s= must therefore read back as the
// ICMP type, not as a nonsensical source port.
r = fw.ParseAdvRule("icmp|in|s=8|d=44.33.22.11", Accept)
require.NotNil(t, r, "expected icmp rule with type in s= to parse")
require.NotNil(t, r.ICMPType, "expected s=8 to be read as icmp type 8")
require.EqualValues(t, 8, *r.ICMPType, "expected icmp type 8 from s=8")
require.False(t, r.HasSourcePorts(), "an icmp type must not be read as a source port")
require.Equal(t, "44.33.22.11", r.Destination)
// A colon range in an advanced rule is invalid (csf uses underscores there).
require.Nil(t, fw.ParseAdvRule("tcp|in|d=1000:2000|s=1.2.3.4", Accept),
"expected colon range in advanced rule to be rejected")
// csf.conf port lists parse single ports and colon ranges.
rules := fw.ParsePorts("20,21,30000:35000", IPv4, TCP, 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")
}

2268
firewall.go Normal file

File diff suppressed because it is too large Load diff

1379
firewall_test.go Normal file

File diff suppressed because it is too large Load diff

1611
firewalld_linux.go Normal file

File diff suppressed because it is too large Load diff

576
firewalld_linux_test.go Normal file
View file

@ -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)
}

26
go.mod Normal file
View file

@ -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
)

120
go.sum Normal file
View file

@ -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=

393
hooks_linux.go Normal file
View file

@ -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)
}

448
hooks_linux_test.go Normal file
View file

@ -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)
}
}

View file

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

View file

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

26
integration_linux_test.go Normal file
View file

@ -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())
}

1848
integration_test.go Normal file

File diff suppressed because it is too large Load diff

View file

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

3162
iptables_linux.go Normal file

File diff suppressed because it is too large Load diff

1801
iptables_linux_test.go Normal file

File diff suppressed because it is too large Load diff

17
manager_darwin.go Normal file
View file

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

17
manager_freebsd.go Normal file
View file

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

51
manager_linux.go Normal file
View file

@ -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")
}

16
manager_windows.go Normal file
View file

@ -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")
}

2222
nft_linux.go Normal file

File diff suppressed because it is too large Load diff

661
nft_linux_test.go Normal file
View file

@ -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 <proto> <type>` (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)
}
}

2119
pf.go Normal file

File diff suppressed because it is too large Load diff

603
pf_test.go Normal file
View file

@ -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 <name>.
{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop},
{Direction: DirOutput, Family: IPv4, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept},
}
for _, r := range rules {
line, err := fw.MarshalRule(r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed, err := fw.UnmarshalRule(line)
require.NoError(t, err, "failed to parse %q", line)
require.True(t, parsed.Equal(r, true),
"round-trip mismatch: input %+v, line %q, output %+v", *r, line, parsed)
}
// A non-address Source is emitted as a pf table reference in angle brackets.
setLine, err := fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop})
require.NoError(t, err)
require.Contains(t, setLine, "from <blocklist>")
// Invalid lines must be rejected.
invalidRules := []string{
"pass in quick inet proto foo from any to any",
"frobnicate in quick from any to any",
"pass sideways quick from any to any",
}
for _, line := range invalidRules {
_, err := fw.UnmarshalRule(line)
require.Error(t, err, "line parsed when it should be invalid: %s", line)
}
// A port without a concrete protocol cannot be expressed in pf.
_, 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 "<path>.tmp" scheme) remains.
_, err = os.Stat(path + ".tmp")
require.True(t, os.IsNotExist(err), "fixed-name temp file must not linger")
// A brand-new file defaults to 0600 rather than 0644.
newPath := filepath.Join(dir, "new.conf")
require.NoError(t, fw.writeFileLines(newPath, []string{"x"}))
fi, err = os.Stat(newPath)
require.NoError(t, err)
require.Equal(t, os.FileMode(0600), fi.Mode().Perm())
}
// readFileLines must handle a pf.conf line longer than bufio.Scanner's default
// 64 KB token cap rather than failing with a "token too long" error.
func TestReadFileLinesLongLine(t *testing.T) {
fw := new(PF)
dir := t.TempDir()
path := filepath.Join(dir, "pf.conf")
long := strings.Repeat("a", 300*1024) // 300 KB, well past the 64 KB default
require.NoError(t, os.WriteFile(path, []byte(long+"\nshort\n"), 0600))
lines, err := fw.readFileLines(path)
require.NoError(t, err, "a long line must not overflow the scanner")
require.Len(t, lines, 2)
require.Equal(t, long, lines[0])
require.Equal(t, "short", lines[1])
}
// 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])
}

Binary file not shown.

380
scripts/refactor_backend.py Normal file
View file

@ -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<Type> constructor stays a global.
The script parses the backend file, classifies every function, and (with --apply)
rewrites the backend file, its _test.go, and bare-name mentions in sibling files.
Default is a dry run that prints the plan. Naming is heuristic review the plan,
and pass --rename a=b,c=d to override any proposed name.
Usage:
scripts/refactor_backend.py csf_linux.go # dry-run plan
scripts/refactor_backend.py csf_linux.go --prefix csf # override token(s)
scripts/refactor_backend.py csf_linux.go --apply
scripts/refactor_backend.py csf_linux.go --apply --rename fooBar=baz
"""
import argparse
import os
import re
import subprocess
import sys
# Go initialisms handled when lowercasing a leading word so an acronym stays intact
# (NATFamilies -> natFamilies, IPv6Unavailable -> ipv6Unavailable, not nATFamilies).
INITIALISMS = [
"ICMPV6", "ICMPV4", "HTTPS", "IPV4", "IPV6", "ICMP", "HTTP", "SCTP",
"NAT", "TCP", "UDP", "DNS", "URL", "URI", "API", "TLS", "SSH", "ACL",
"ID", "IP", "WFP", "UFW", "CSF", "APF", "NFT", "PF",
]
# Group 1: receiver variable (optional — a method may use an anonymous receiver
# like `func (*IPTables) IgnoreLine(...)`). Group 2: receiver type. Group 3: name.
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()

245
scripts/refactor_order.py Normal file
View file

@ -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<Type> constructor is placed immediately after the preamble.
3. Remaining functions are ordered to match the `type Manager interface` list.
4. Any helper must appear before the first function that calls it; dependencies are
therefore hoisted in front of the exported method that needs them.
Doc comments attached to a function are moved together with the function.
Usage:
scripts/refactor_order.py apf_linux.go # dry-run to stdout
scripts/refactor_order.py apf_linux.go --apply # rewrite in place
scripts/refactor_order.py apf_linux.go --apply --gofmt # rewrite and format
"""
import argparse
import os
import re
import subprocess
import sys
# 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"(?<!\.)\b([A-Za-z_]\w*)\(", text):
called = mm.group(1)
if called != name and called in names:
deps.add(called)
return graph
def reorder(blocks, manager_order, constructor):
"""Return blocks in the required order."""
backend_type, receiver = detect_backend_type_and_receiver(blocks)
graph = build_call_graph(blocks, receiver)
name_to_block = {block["name"]: block for block in blocks}
names = set(name_to_block.keys())
if constructor not in names:
die(f"constructor {constructor} not found in file")
missing = [m for m in manager_order if m not in names]
if missing:
die(f"Manager interface methods missing from file: {missing}")
order = []
placed = set()
def place(name):
if name in placed:
return
if name not in name_to_block:
return
for dep in sorted(graph.get(name, set())):
place(dep)
order.append(name)
placed.add(name)
# 1. Constructor after preamble, before other functions.
place(constructor)
# 2. Manager interface methods in interface order.
for method in manager_order:
place(method)
# 3. Any leftover functions (e.g. helper-only internals not reachable from
# the public surface) are appended in dependency order.
for name in sorted(names):
if name not in placed:
place(name)
return [name_to_block[name] for name in order]
def die(msg):
print("error: " + msg, file=sys.stderr)
sys.exit(1)
def main():
ap = argparse.ArgumentParser(
description="Reorder a go-firewall backend to match the Manager interface and dependency order."
)
ap.add_argument("file", help="backend source file (e.g. apf_linux.go)")
ap.add_argument(
"--interface",
default="firewall.go",
help="file containing the Manager interface (default: firewall.go)",
)
ap.add_argument(
"--constructor",
help="constructor name override (default: New<Type> auto-detected)",
)
ap.add_argument("--apply", action="store_true", help="rewrite the file in place")
ap.add_argument("--gofmt", action="store_true", help="run gofmt after rewriting")
args = ap.parse_args()
if not os.path.isfile(args.file):
die(f"no such file: {args.file}")
text = open(args.file).read()
preamble, blocks = split_blocks(text)
if not blocks:
die("no functions found in file")
backend_type, _ = detect_backend_type_and_receiver(blocks)
constructor = args.constructor or ("New" + backend_type)
manager_order = parse_manager_interface(args.interface)
ordered = reorder(blocks, manager_order, constructor)
new_text = preamble + "".join(block["text"] for block in ordered)
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()

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

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

View file

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

View file

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

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

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

View file

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

1588
ufw_linux.go Normal file

File diff suppressed because it is too large Load diff

628
ufw_linux_test.go Normal file
View file

@ -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")
}

166
utils.go Normal file
View file

@ -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
}

906
wf_windows.go Normal file
View file

@ -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())
}

296
wf_windows_test.go Normal file
View file

@ -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)
})
}
}