go-firewall/integration_test.go
2026-07-08 15:54:48 -05:00

1848 lines
79 KiB
Go

//go:build integration
// Package firewall integration tests exercise the real firewall backends
// end-to-end (add a rule, read it back, remove it) rather than the marshal
// helpers the other _test.go files cover. They are gated behind the `integration`
// build tag so a normal `go test ./...` never touches a live firewall; run them
// with `go test -tags integration`.
//
// This file holds the platform-independent core — the capability-driven
// runManagerSuite and its helpers. Each OS has an integration_<goos>_test.go with a
// TestIntegration that lists the backends available there (nft/iptables/firewalld/
// ufw/csf/apf on Linux, pf on freebsd/darwin, wf on windows) and hands them to
// runIntegration. The suite is capability-driven: for each backend it inspects
// Capabilities() and exercises exactly the features that backend advertises,
// skipping the rest.
//
// These tests need privileges and the backend's real tooling, and they mutate the
// live firewall state of the machine they run on, so they are meant to run inside
// the throwaway VMs/containers under test/integration/, not on a workstation. Set
// FIREWALL_BACKEND to target one backend (a construction failure is then fatal,
// since the environment is expected to provide it); leave it unset to run whatever
// backends are present, skipping the rest.
package firewall
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// integrationPrefix namespaces every rule, set and table this suite creates so
// its writes are distinguishable from anything else on the box and, combined with
// the isolated container netns, are safe to clean up.
const integrationPrefix = "gofwit"
// backendFactory pairs a backend's name with its constructor so a platform's
// TestIntegration can build them from a single table.
type backendFactory struct {
name string
new func(ctx context.Context, rulePrefix string) (Manager, error)
}
// runIntegration constructs each backend in backends and runs the capability suite
// against it. It honors FIREWALL_BACKEND: when set, only that backend runs and a
// construction failure is fatal (the environment is expected to provide it); when
// unset, backends that fail to construct are skipped. Each platform's
// TestIntegration (in the per-OS integration_<goos>_test.go files) calls this with
// the backends available there.
func runIntegration(t *testing.T, backends []backendFactory) {
// Trim whitespace: a value passed through a Windows `set VAR=x && ...` picks up
// a trailing space, and a shell may add a stray CR.
want := strings.TrimSpace(os.Getenv("FIREWALL_BACKEND"))
ran := 0
for _, b := range backends {
if want != "" && b.name != want {
continue
}
t.Run(b.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
mgr, err := b.new(ctx, integrationPrefix)
if err != nil {
if want != "" {
t.Fatalf("construct %s backend: %v", b.name, err)
}
t.Skipf("%s backend not available: %v", b.name, err)
}
// reconstruct builds a fresh manager of the same backend, simulating a
// process restart against firewall state that outlives the process. Some
// invariants (a set default policy surviving a later mutation) only hold
// across a fresh instance, so the suite needs to build one on demand.
reconstruct := func(ctx context.Context) (Manager, error) {
return b.new(ctx, integrationPrefix)
}
runManagerSuite(t, mgr, reconstruct)
})
ran++
}
if want != "" && ran == 0 {
t.Fatalf("FIREWALL_BACKEND=%q does not name a known backend", want)
}
}
// runManagerSuite runs the capability-driven feature suite against a constructed
// manager. Each feature is a subtest gated on the backend's advertised
// Capabilities(); unsupported features are skipped rather than exercised.
// reconstruct builds a fresh manager of the same backend for invariants that only
// hold across a simulated process restart.
func runManagerSuite(t *testing.T, mgr Manager, reconstruct func(context.Context) (Manager, error)) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
caps := mgr.Capabilities()
t.Logf("backend %s output=%v capabilities=%+v", mgr.Type(), caps.Output, caps)
// Resolve the zone once. firewalld returns its default zone; the others
// return "" and ignore the argument.
zone, err := mgr.GetZone(ctx, "")
if err != nil {
zone = ""
}
defer func() {
// Best-effort: activate whatever the suite left behind, then close. Both are
// part of the Manager contract and worth exercising once per backend.
_ = mgr.Reload(ctx)
_ = mgr.Close(ctx)
}()
// --- filter-rule features -------------------------------------------------
t.Run("basic", func(t *testing.T) {
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Action: Accept})
})
t.Run("protoonly", func(t *testing.T) {
// A bare protocol match with no port and no address ("allow all TCP inbound").
// Regression for ufw dropping the protocol and emitting a bare `allow in`,
// which ufw rejects. Backends that cannot express a portless, address-less
// protocol match skip via the ErrUnsupported sentinel.
rule := &Rule{Proto: TCP, Action: Accept}
err := mgr.AddRule(ctx, zone, rule)
if errors.Is(err, ErrUnsupported) {
t.Skip("backend cannot express a portless, address-less protocol match")
}
require.NoError(t, err)
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
rules := rulesOf(t, ctx, mgr, zone)
require.True(t, containsRule(rules, rule, mgr.Capabilities().Output),
"bare TCP rule not found in %s", dumpRules(rules))
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output),
"bare TCP rule still present after removal")
})
t.Run("hasprefix", func(t *testing.T) {
// A rule this library adds must read back exactly once — nft and pf also
// list foreign tables/anchors and must not re-list their own — and report
// HasPrefix: the suite runs with a non-empty prefix, so every backend either
// tags its rules with that prefix or isolates them in its own container.
//
// The rule carries a source address on purpose. An address-less port accept
// lands in apf/csf's native shared port lists (conf.apf IG_TCP_CPORTS,
// csf.conf TCP_IN) — a comma-separated value on a single config line with
// nowhere to attach a per-rule prefix, so HasPrefix is legitimately false
// there (documented in apf_linux.go/csf_linux.go). A host+port accept instead
// routes those backends into their taggable allow files, exercising the
// HasPrefix contract on a rule form every backend can tag or isolate.
rule := &Rule{Proto: TCP, Port: 3456, Source: "192.0.2.10/32", Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, rule))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
var matches []*Rule
for _, r := range rulesOf(t, ctx, mgr, zone) {
if r.EqualBase(rule, mgr.Capabilities().Output) {
matches = append(matches, r)
}
}
require.Len(t, matches, 1, "an added rule must read back exactly once, got %s", dumpRules(matches))
require.True(t, matches[0].HasPrefix, "a rule this library added must report HasPrefix")
})
t.Run("output", func(t *testing.T) {
requireCap(t, caps.Output)
roundTripRule(t, ctx, mgr, zone, &Rule{Direction: DirOutput, Proto: TCP, Port: 8080, Action: Accept})
})
t.Run("forward", func(t *testing.T) {
requireCap(t, caps.Forward)
// A classic routed-traffic filter: allow forwarding TCP from one network to
// another through the host. It binds no interface, so it does not depend on
// the test host having a particular NIC.
roundTripRule(t, ctx, mgr, zone, &Rule{
Direction: DirForward, Family: IPv4, Proto: TCP, Port: 8080,
Source: "192.0.2.0/24", Destination: "198.51.100.0/24", Action: Accept,
})
})
t.Run("hostaddress", func(t *testing.T) {
// A rule matching a single host address, written with an explicit /32.
// Backends re-spell an address on read — nft and ufw strip the /32,
// firewalld requires and stores a family, iptables-save adds the /32 — so
// this is where a rule silently fails to read back or reconcile. Left
// FamilyAny so the backend must resolve the family from the address itself.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "192.0.2.10/32", Action: Accept})
})
t.Run("hosthooknoport", func(t *testing.T) {
// csf/apf trust files store a single all-protocol address and their advanced
// rule holds a single port, so a portless concrete-protocol host, a
// source+destination pair, and (for apf) a multi-port list have no trust-file
// form. apf routes all of these to the raw-iptables hook (hostNeedsHook /
// needsHook); csf routes the host shapes to the hook and expresses a
// multi-port list natively as a comma list. Either way each must round-trip.
// Gated to csf/apf; the chain backends express these natively (covered
// elsewhere).
switch mgr.Type() {
case CSFType, APFType:
default:
t.Skip("only csf/apf route these shapes to the hook")
}
// A concrete-protocol host with no port.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Source: "192.0.2.40/32", Action: Accept})
// A source+destination pair with no port.
roundTripRule(t, ctx, mgr, zone, &Rule{Source: "192.0.2.41/32", Destination: "192.0.2.42/32", Action: Accept})
// A multi-port list as a deny: apf writes it to the hook with its literal
// action, while csf writes it to csf.deny, whose action follows csf.conf
// (stock DROP). Probe both actions and round-trip the first the backend
// accepts, mirroring the denyaddress case.
roundTripVariants(t, ctx, mgr, zone,
&Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Reject},
&Rule{Proto: TCP, Ports: []PortRange{{Start: 5001, End: 5001}, {Start: 5002, End: 5002}}, Action: Drop},
)
// A multi-port list as a host accept: apf hooks it, csf writes an advanced
// rule; no action ambiguity, so a direct round-trip covers both.
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 6001, End: 6001}, {Start: 6002, End: 6002}}, Source: "192.0.2.43/32", Action: Accept})
})
t.Run("negatedaddress", func(t *testing.T) {
// ufw's tuple grammar cannot negate an address, so a negated source is routed
// to the before.rules raw path (iptables `! -s`) rather than rejected, and
// must round-trip through it. Gated to ufw: this exercises that specific
// reroute; other backends negate (or reject) through their own paths.
if mgr.Type() != UFWType {
t.Skip("exercises ufw's negated-address reroute to before.rules")
}
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "!192.0.2.50/32", Action: Accept})
})
t.Run("denyaddress", func(t *testing.T) {
// A deny rule carrying a host address exercises the deny-list address path
// (csf.deny / apf deny_hosts, and the reject chains elsewhere). Reject is
// used because the address-list backends canonicalize a deny to it; Windows
// Filtering Platform has no reject action and falls back to Drop.
roundTripVariants(t, ctx, mgr, zone,
&Rule{Family: IPv4, Proto: TCP, Port: 3389, Source: "192.0.2.20/32", Action: Reject},
&Rule{Family: IPv4, Proto: TCP, Port: 3389, Source: "192.0.2.20/32", Action: Drop},
)
})
t.Run("denyactionfromconfig", func(t *testing.T) {
// csf.deny / apf deny_hosts encode no action of their own: the tool applies
// its deny chain with an action taken from config — csf.conf DROP (stock
// default DROP), apf conf.apf ALL_STOP (stock default DROP). A deny whose
// action matches that is stored natively and must read back with it, not a
// fixed Reject — or a caller managing a Drop rule sees it churn on every Sync
// (read back as Reject, never equal to the desired Drop). A deny whose action
// differs has no native form but is expressible directly in iptables, so these
// backends inject it through their pre-hook rather than refusing it; it must
// round-trip with its exact action. Only the csf/apf address-list backends
// derive a deny's action from config this way.
switch mgr.Type() {
case "csf", "apf":
default:
t.Skip("backend does not derive a deny's action from config")
}
// The config-matching action (Drop on a stock host) is stored natively; the
// opposite action is injected through the hook. Both must read back exactly, so
// exercise both rather than assuming which one the host's config makes native.
for _, deny := range []*Rule{
{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.30/32", Action: Drop},
{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.31/32", Action: Reject},
} {
require.NoError(t, mgr.AddRule(ctx, zone, deny),
"a deny must be storable natively or through the hook, never refused: %+v", deny)
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, deny) })
// The rule must read back with its exact action so it compares equal to the
// desired rule and Sync leaves it in place rather than churning it.
got := findRule(t, ctx, mgr, zone, deny)
require.Equal(t, deny.Action, got.Action,
"a deny must read back with its exact action, not the config's default: %+v", deny)
require.True(t, got.Equal(deny, mgr.Capabilities().Output),
"the read-back deny must equal the desired rule so Sync does not churn it: %+v", deny)
require.NoError(t, mgr.RemoveRule(ctx, zone, deny))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), deny, mgr.Capabilities().Output),
"deny rule still present after removal: %+v", deny)
}
})
t.Run("removeclearscustomerhookcopy", func(t *testing.T) {
// A rule shape csf/apf can express natively might also have been placed in the
// pre-hook by hand — a customer editing csfpre.sh / hook_pre.sh. RemoveRule must
// clear the hook copy too: routing such a rule only to the native path would
// remove it from the config while leaving it running in the hook. Only the
// csf/apf address-list backends carry a pre-hook, so plant the rule directly in
// theirs to stand in for the hand-added copy.
var plant func(*Rule) error
switch b := mgr.(type) {
case *APF:
plant = func(r *Rule) error { _, err := b.hook().edit(r, false); return err }
case *CSF:
plant = func(r *Rule) error { _, err := b.hook().edit(r, false); return err }
default:
t.Skip("backend has no pre-hook")
}
// A host+port accept is a natively expressible shape, so RemoveRule routes it to
// the config; before the fix it never looked in the hook.
r := &Rule{Family: IPv4, Proto: TCP, Port: 4567, Source: "192.0.2.60/32", Action: Accept}
require.NoError(t, plant(r), "planting the customer hook copy must succeed")
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output),
"the planted hook rule must read back")
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output),
"RemoveRule must clear a native-shaped rule's hook copy, not leave it running")
})
t.Run("denyport", func(t *testing.T) {
// A port-only deny (a port with no address) exercises the address-less deny
// path. Native backends write it directly; the csf/apf address-list backends
// have no way to carry a bare port, so they must synthesize the "any" network
// (0.0.0.0/0 // ::/0) as a placeholder address — otherwise the line is parsed
// but silently never applied by the tool, leaving the port open while AddRule
// reports success. The deny action follows config on those backends (csf.conf
// DROP / apf ALL_STOP, stock default Drop), so try Reject first (native reject
// backends) then Drop (the address-list default); roundTripVariants asserts the
// accepted form is actually present after add, which fails if nothing was written.
roundTripVariants(t, ctx, mgr, zone,
&Rule{Proto: TCP, Port: 3391, Action: Reject},
&Rule{Proto: TCP, Port: 3391, Action: Drop},
)
})
t.Run("mergedfamilyremove", func(t *testing.T) {
// A v4 rule and its v6 twin differ only in Family, so GetRules collapses the
// pair into one FamilyAny rule. Removing that read-back rule must clear BOTH
// underlying rows. Regression for a family-strict remove that could not match
// a merged rule at all (a silent no-op that left the port open) or that
// removed only one of the two rows.
const port = 3492
v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept}
v6 := &Rule{Family: IPv6, Proto: TCP, Port: port, Action: Accept}
added := 0
for _, r := range []*Rule{v4, v6} {
err := mgr.AddRule(ctx, zone, r)
if errors.Is(err, ErrUnsupported) {
continue // the backend cannot express this family of a bare-port accept.
}
require.NoError(t, err)
added++
}
if added == 0 {
t.Skip("backend expresses neither family of a bare-port accept")
}
t.Cleanup(func() {
_ = mgr.RemoveRule(ctx, zone, v4)
_ = mgr.RemoveRule(ctx, zone, v6)
})
portRule := func(r *Rule) bool { return r.Proto == TCP && r.Port == port }
// With both families expressible, GetRules must report coverage for BOTH. A
// cross-family dedup that matches on a family-agnostic Equal would drop the
// v6 add and leave IPv6 unprotected; a concrete-family removal must then clear
// only its own family and leave the twin in place.
if added == 2 {
familyCoverage := func() (v4Cov, v6Cov bool) {
for _, r := range rulesOf(t, ctx, mgr, zone) {
if !portRule(r) {
continue
}
switch r.Family {
case FamilyAny:
v4Cov, v6Cov = true, true
case IPv4:
v4Cov = true
case IPv6:
v6Cov = true
}
}
return
}
has4, has6 := familyCoverage()
require.True(t, has4 && has6,
"both families must be present after adding the v4/v6 pair; a missing family means a cross-family add was silently dropped")
require.NoError(t, mgr.RemoveRule(ctx, zone, v4))
has4, has6 = familyCoverage()
require.True(t, has6, "removing the v4 twin must leave the v6 twin in place")
require.False(t, has4, "removing the v4 twin must not leave a v4 row behind")
// Restore the pair so the remove-all phase below starts from both rows.
require.NoError(t, mgr.AddRule(ctx, zone, v4))
}
// Remove each rule the backend reports for this port (one merged FamilyAny
// rule, or one per family), then confirm none remain — removing the merged
// rule must not leave a twin row behind.
for _, r := range rulesOf(t, ctx, mgr, zone) {
if portRule(r) {
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
}
}
for _, r := range rulesOf(t, ctx, mgr, zone) {
require.False(t, portRule(r), "port %d rule still present after removal: %+v", port, r)
}
})
t.Run("familyanysplitremove", func(t *testing.T) {
// Unlike mergedfamilyremove (which adds a v4/v6 PAIR), this adds ONE FamilyAny
// rule. A FamilyAny rule with no address is stored as a single dual-family
// object by the unified backends (nft inet, pf without af, firewalld's
// dual-stack zone entries) and as one row per family by the separated backends;
// either way GetRules reports coverage for both families. Removing a single
// family must leave the other in place — the backend splits the dual object.
// Where its model cannot express a single-family rule of the shape (apf's
// dual-stack port lists, wf's address-less filters) it rejects the removal with
// ErrUnsupported instead. Regression for a concrete-family removal that dropped
// both families (nft/pf over-remove) or no-oped and left both (firewalld zone
// entries under-remove).
// splitCase is one dual-family rule shape plus a matcher over GetRules output.
type splitCase struct {
anyRule, v4, v6 *Rule
match func(*Rule) bool
}
// runSplit exercises a shape: add the FamilyAny rule, remove each family in
// turn, and confirm the other survives (or the backend rejects the removal).
runSplit := func(t *testing.T, c splitCase) {
coverage := func() (v4Cov, v6Cov bool) {
for _, r := range rulesOf(t, ctx, mgr, zone) {
if !c.match(r) {
continue
}
switch r.Family {
case FamilyAny:
v4Cov, v6Cov = true, true
case IPv4:
v4Cov = true
case IPv6:
v6Cov = true
}
}
return
}
clear := func() {
for _, r := range rulesOf(t, ctx, mgr, zone) {
if c.match(r) {
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
}
}
}
// The backend must express this FamilyAny shape with dual coverage; skip
// where it cannot (csf/apf need an address, etc.).
if err := mgr.AddRule(ctx, zone, c.anyRule); errors.Is(err, ErrUnsupported) {
t.Skip("backend cannot express this bare FamilyAny shape")
} else {
require.NoError(t, err)
}
t.Cleanup(clear)
if has4, has6 := coverage(); !(has4 && has6) {
clear()
t.Skipf("backend does not give this FamilyAny shape dual coverage (v4=%v v6=%v)", has4, has6)
}
// Remove IPv4; IPv6 must survive. A backend that cannot express a
// single-family rule of this shape rejects with ErrUnsupported, so skip.
if err := mgr.RemoveRule(ctx, zone, c.v4); errors.Is(err, ErrUnsupported) {
t.Skip("backend cannot express a single-family removal of this dual-family shape")
} else {
require.NoError(t, err)
}
has4, has6 := coverage()
require.False(t, has4, "removing IPv4 must clear IPv4 coverage")
require.True(t, has6, "removing IPv4 from a FamilyAny rule must leave IPv6 in place")
// Opposite direction from a clean slate: remove IPv6, IPv4 must survive.
clear()
require.NoError(t, mgr.AddRule(ctx, zone, c.anyRule))
if has4, has6 := coverage(); !(has4 && has6) {
t.Fatalf("re-adding the FamilyAny rule must restore both families (v4=%v v6=%v)", has4, has6)
}
require.NoError(t, mgr.RemoveRule(ctx, zone, c.v6))
has4, has6 = coverage()
require.False(t, has6, "removing IPv6 must clear IPv6 coverage")
require.True(t, has4, "removing IPv6 from a FamilyAny rule must leave IPv4 in place")
}
t.Run("destport", func(t *testing.T) {
const p uint16 = 3493
runSplit(t, splitCase{
anyRule: &Rule{Family: FamilyAny, Proto: TCP, Port: p, Action: Accept},
v4: &Rule{Family: IPv4, Proto: TCP, Port: p, Action: Accept},
v6: &Rule{Family: IPv6, Proto: TCP, Port: p, Action: Accept},
match: func(r *Rule) bool {
s := r.PortSpecs()
return r.Proto == TCP && len(s) == 1 && s[0].Start == p && !r.HasSourcePorts()
},
})
})
t.Run("sourceport", func(t *testing.T) {
const p uint16 = 3494
runSplit(t, splitCase{
anyRule: &Rule{Family: FamilyAny, Proto: TCP, SourcePort: p, Action: Accept},
v4: &Rule{Family: IPv4, Proto: TCP, SourcePort: p, Action: Accept},
v6: &Rule{Family: IPv6, Proto: TCP, SourcePort: p, Action: Accept},
match: func(r *Rule) bool {
s := r.SourcePortSpecs()
return r.Proto == TCP && len(s) == 1 && s[0].Start == p && !r.HasPorts()
},
})
})
t.Run("ordering", func(t *testing.T) {
requireCap(t, caps.RuleOrdering)
// On an ordered backend the surviving family must keep the dual rule's place
// in the chain, not jump to the end after the split re-adds it. AddRule order
// is backend-specific (nft appends, iptables prepends), so read the actual
// order rather than assume it, then assert the split leaves it unchanged. The
// split removes IPv6 and every probe rule's survivor is IPv4, so a
// family-separated backend (iptables/ufw read their v4 chain first) keeps the
// survivor in its slot too — no false failure there.
const before, dual, after uint16 = 3496, 3497, 3498
rBefore := &Rule{Family: IPv4, Proto: TCP, Port: before, Action: Accept}
rDual := &Rule{Family: FamilyAny, Proto: TCP, Port: dual, Action: Accept}
rAfter := &Rule{Family: IPv4, Proto: TCP, Port: after, Action: Accept}
for _, r := range []*Rule{rBefore, rDual, rAfter} {
if err := mgr.AddRule(ctx, zone, r); errors.Is(err, ErrUnsupported) {
t.Skip("backend cannot express one of the ordering probe rules")
} else {
require.NoError(t, err)
}
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
}
ports := []uint16{before, dual, after}
order0 := managedPorts(t, ctx, mgr, zone, ports)
require.Len(t, order0, 3, "all three probe rules should be present before the split")
// Split off IPv6; the surviving IPv4 row must keep the dual rule's position.
require.NoError(t, mgr.RemoveRule(ctx, zone, &Rule{Family: IPv6, Proto: TCP, Port: dual, Action: Accept}))
require.Equal(t, order0, managedPorts(t, ctx, mgr, zone, ports),
"the re-added surviving family must keep the dual rule's position")
})
})
t.Run("diranysplitremove", func(t *testing.T) {
// A DirAny rule applies in BOTH directions. On the chain backends it is stored
// as an inbound rule plus a role-swapped outbound rule (two physical rows); on
// csf/apf a bare host is one bidirectional plain csf.allow/allow_hosts line and
// any ported shape fans out into two hook rules. Either way GetRules collapses
// it back to one DirAny rule. Removing a single direction must leave the other
// in place — the chain backends drop only that direction's row, while csf/apf
// either split their plain line and re-express the survivor through the
// raw-iptables hook (bare host) or drop just that direction's hook rule
// (ported). Backends with no output concept (firewalld) reject the DirAny add
// with ErrUnsupported and skip. This is the direction analog of
// familyanysplitremove: removing the input half of a DirAny rule must never
// drop the output half, mirroring a concrete-family removal leaving the other
// family in place.
requireCap(t, caps.Output)
// splitCase is one dual-direction rule shape, the concrete-direction removal
// targets (inbound framed as a source match, outbound as the role-swapped
// destination match), and a matcher reporting which directions a read-back rule
// covers for the shape.
type splitCase struct {
anyRule, inTarget, outTarget *Rule
cover func(*Rule) (inCov, outCov bool)
}
// runSplit exercises a shape: add the DirAny rule, remove each direction in
// turn, and confirm the other survives (or the backend rejects the removal).
runSplit := func(t *testing.T, c splitCase) {
coverage := func() (inCov, outCov bool) {
for _, r := range rulesOf(t, ctx, mgr, zone) {
in, out := c.cover(r)
inCov = inCov || in
outCov = outCov || out
}
return
}
clear := func() {
_ = mgr.RemoveRule(ctx, zone, c.anyRule)
_ = mgr.RemoveRule(ctx, zone, c.inTarget)
_ = mgr.RemoveRule(ctx, zone, c.outTarget)
}
// The backend must express this bidirectional shape; skip where it cannot.
if err := mgr.AddRule(ctx, zone, c.anyRule); errors.Is(err, ErrUnsupported) {
t.Skip("backend cannot express this DirAny shape")
} else {
require.NoError(t, err)
}
t.Cleanup(clear)
if inCov, outCov := coverage(); !(inCov && outCov) {
clear()
t.Skipf("backend does not give this DirAny shape dual-direction coverage (in=%v out=%v)", inCov, outCov)
}
// Remove the input direction; output must survive.
if err := mgr.RemoveRule(ctx, zone, c.inTarget); errors.Is(err, ErrUnsupported) {
t.Skip("backend cannot express a single-direction removal of this DirAny shape")
} else {
require.NoError(t, err)
}
inCov, outCov := coverage()
require.False(t, inCov, "removing the input direction must clear input coverage")
require.True(t, outCov, "removing the input direction from a DirAny rule must leave output in place")
// Opposite direction from a clean slate: remove output, input must survive.
clear()
require.NoError(t, mgr.AddRule(ctx, zone, c.anyRule))
if inCov, outCov := coverage(); !(inCov && outCov) {
t.Fatalf("re-adding the DirAny rule must restore both directions (in=%v out=%v)", inCov, outCov)
}
require.NoError(t, mgr.RemoveRule(ctx, zone, c.outTarget))
inCov, outCov = coverage()
require.False(t, outCov, "removing the output direction must clear output coverage")
require.True(t, inCov, "removing the output direction from a DirAny rule must leave input in place")
}
t.Run("barehost", func(t *testing.T) {
// A bare host allow: on csf/apf this is the single-plain-line shape whose
// single-direction removal splits the line and re-adds the survivor via the
// hook (splitDualRowDirection).
const host = "192.0.2.77"
runSplit(t, splitCase{
anyRule: &Rule{Direction: DirAny, Source: host, Action: Accept},
inTarget: &Rule{Direction: DirInput, Source: host, Action: Accept},
outTarget: &Rule{Direction: DirOutput, Destination: host, Action: Accept},
cover: func(r *Rule) (inCov, outCov bool) {
switch r.Direction {
case DirAny:
if addrEqual(r.Source, host) {
return true, true
}
case DirInput:
if addrEqual(r.Source, host) {
return true, false
}
case DirOutput:
if addrEqual(r.Destination, host) {
return false, true
}
}
return false, false
},
})
})
t.Run("portedhost", func(t *testing.T) {
// A ported DirAny rule is NOT a bare-host plain line: it fans out into an
// inbound dport row and its role-swapped outbound sport twin — two physical
// rows on the chain backends, two hook rules on csf/apf. The two rows share
// an identical inbound-frame match, so only the direction guard in
// EqualForRemoval keeps a single-direction removal from taking the twin as
// well. This is the direction analog of the family split's destport/
// sourceport cases.
const host = "192.0.2.81"
const p uint16 = 3499
runSplit(t, splitCase{
anyRule: &Rule{Direction: DirAny, Proto: TCP, Port: p, Source: host, Action: Accept},
inTarget: &Rule{Direction: DirInput, Proto: TCP, Port: p, Source: host, Action: Accept},
outTarget: &Rule{Direction: DirOutput, Proto: TCP, SourcePort: p, Destination: host, Action: Accept},
cover: func(r *Rule) (inCov, outCov bool) {
if r.Proto != TCP {
return false, false
}
soleDest := func() bool {
s := r.PortSpecs()
return len(s) == 1 && s[0].Start == p && !r.HasSourcePorts()
}
soleSource := func() bool {
s := r.SourcePortSpecs()
return len(s) == 1 && s[0].Start == p && !r.HasPorts()
}
switch r.Direction {
case DirAny:
// Merged back into the inbound frame: dport p from the host.
if addrEqual(r.Source, host) && soleDest() {
return true, true
}
case DirInput:
if addrEqual(r.Source, host) && soleDest() {
return true, false
}
case DirOutput:
// The surviving outbound twin: sport p to the host.
if addrEqual(r.Destination, host) && soleSource() {
return false, true
}
}
return false, false
},
})
})
})
t.Run("diranyroundtrip", func(t *testing.T) {
// A DirAny rule added and read back must round-trip as exactly one DirAny rule,
// and a second add of the same rule must be an idempotent no-op (the merged
// DirAny rule dedups a re-add) rather than doubling the underlying rows.
requireCap(t, caps.Output)
rule := &Rule{Direction: DirAny, Source: "192.0.2.78", Action: Accept}
if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) {
t.Skip("backend cannot express a bidirectional bare host allow")
} else {
require.NoError(t, err)
}
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
matches := func() int {
n := 0
for _, r := range rulesOf(t, ctx, mgr, zone) {
if r.Direction == DirAny && addrEqual(r.Source, "192.0.2.78") {
n++
}
}
return n
}
require.Equal(t, 1, matches(), "a DirAny rule must read back as exactly one DirAny rule")
// A redundant add must not create a second copy.
require.NoError(t, mgr.AddRule(ctx, zone, rule))
require.Equal(t, 1, matches(), "re-adding an existing DirAny rule must be a no-op")
})
t.Run("diranyported", func(t *testing.T) {
// A DirAny rule that is NOT a bare host — here a host + destination port — must
// still round-trip: it fans out into an inbound (dport) rule and its role-
// swapped outbound (sport) twin, which collapse back to one DirAny rule on read.
// This exercises the fan-out path that a plain csf.allow/apf line (bare host)
// does not use. Skip where the backend cannot express the shape.
requireCap(t, caps.Output)
rule := &Rule{Direction: DirAny, Proto: TCP, Port: 22, Source: "192.0.2.80", Action: Accept}
if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) {
t.Skip("backend cannot express this DirAny ported host rule")
} else {
require.NoError(t, err)
}
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, caps.Output),
"a DirAny ported host rule must read back as one DirAny rule")
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, caps.Output),
"DirAny ported host rule still present after removal")
})
t.Run("diranynooutputfallback", func(t *testing.T) {
// On a backend with no output concept (firewalld), a DirAny rule cannot be a
// both-directions rule, so it must degrade to its input half rather than error:
// the add succeeds and reads back as an input rule, and the same DirAny target
// removes it.
if caps.Output {
t.Skip("backend distinguishes output; DirAny fans out instead of degrading")
}
const host = "192.0.2.79"
rule := &Rule{Direction: DirAny, Source: host, Action: Accept}
if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) {
t.Skip("backend cannot express this host allow at all")
} else {
require.NoError(t, err, "a DirAny rule must degrade to input, not error, on a no-output backend")
}
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
found := false
for _, r := range rulesOf(t, ctx, mgr, zone) {
if r.Direction == DirInput && addrEqual(r.Source, host) {
found = true
}
}
require.True(t, found, "the degraded DirAny rule must read back as an input rule")
// Removal by the same DirAny target must clear it.
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
for _, r := range rulesOf(t, ctx, mgr, zone) {
require.Falsef(t, addrEqual(r.Source, host), "degraded DirAny rule still present: %+v", r)
}
})
t.Run("icmp", func(t *testing.T) {
// ICMP has genuinely different shapes per backend: nft/iptables/ufw accept a
// bare rule; apf models ICMP as a list of allowed types so it needs a type
// (and forbids an address); csf builds ICMP on host-based rules so it needs
// an address AND a type (its advanced-rule format carries the type in the
// single port-flow field, so an address with no type is silently dropped by
// csf). Offer all forms and use the first the backend accepts.
roundTripVariants(t, ctx, mgr, zone,
&Rule{Family: IPv4, Proto: ICMP, Action: Accept},
&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.0/24", Action: Accept},
)
})
t.Run("icmpv6", func(t *testing.T) {
requireCap(t, caps.ICMPv6)
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv6, Proto: ICMPv6, Action: Accept})
})
t.Run("reloadv6raw", func(t *testing.T) {
requireCap(t, caps.ICMPv6)
// An IPv6 rule a backend keeps in its own IPv6 rule file must survive an
// actual reload/apply. ufw re-applies before6.rules through ip6tables-restore
// on `ufw reload`, so the file must reference the correct `ufw6-` chain names;
// apf must be able to run `apf --restart`; csf must ride out its restart lock.
r := &Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, r))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
require.NoError(t, mgr.Reload(ctx), "reload must succeed with an IPv6 raw rule present")
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), "rule missing after reload")
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
require.NoError(t, mgr.Reload(ctx), "reload must succeed after removing the IPv6 raw rule")
})
t.Run("icmptype", func(t *testing.T) {
// A specific ICMP type, with an address fallback for csf (which requires one
// on any ICMP rule). Use type 13 (Timestamp) rather than the more common
// echo-request type 8 to avoid colliding with Windows' built-in echo-request
// rules, which cannot be removed and would make the round-trip assertions
// match the wrong rule.
roundTripVariants(t, ctx, mgr, zone,
&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Accept},
&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.0/24", Action: Accept},
)
})
t.Run("apficmphook", func(t *testing.T) {
// apf carries ICMP as a zone-wide allowed-type list (IG_ICMP_TYPES), so an
// ICMP rule with an address or a non-accept action has no native form and
// routes to the hook (needsHook). The shared icmp/icmptype probes match
// apf on its native address-less accept form first, so exercise the hook path
// here. Gated to apf; type 13 (Timestamp) avoids echo-request collisions.
if mgr.Type() != APFType {
t.Skip("apf-specific ICMP hook routing")
}
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.70/32", Action: Accept})
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Drop})
})
t.Run("apfsinglefamilyport", func(t *testing.T) {
// apf's CPORTS lists are dual-stack, so a single-family bare port accept has
// no native form and is written per-family through the hook
// (dualStackPortNeedsHook) rather than rejected. It must round-trip on its
// own; the FamilyAny→single-family split is exercised by the destport split
// test. Gated to apf.
if mgr.Type() != APFType {
t.Skip("apf-specific single-family CPORTS-to-hook routing")
}
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: TCP, Port: 8090, Action: Accept})
})
t.Run("portrange", func(t *testing.T) {
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept})
})
t.Run("portlist", func(t *testing.T) {
requireCap(t, caps.PortList)
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept})
})
t.Run("sourceport", func(t *testing.T) {
// Source-port alone where supported (note firewalld rich rules can match a
// source port but not together with a destination port, so the probe never
// combines the two); apf requires an address on a source-port rule, so fall
// back to a form carrying a source address.
roundTripVariants(t, ctx, mgr, zone,
&Rule{Proto: TCP, SourcePort: 1234, Action: Accept},
&Rule{Proto: TCP, SourcePort: 1234, Source: "192.0.2.0/24", Action: Accept},
)
})
t.Run("connstate", func(t *testing.T) {
requireCap(t, caps.ConnState)
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, State: StateEstablished | StateRelated, Action: Accept})
})
t.Run("interface", func(t *testing.T) {
requireCap(t, caps.InterfaceMatch)
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, InInterface: "eth0", Action: Accept})
})
t.Run("logging", func(t *testing.T) {
requireCap(t, caps.Logging)
// pf logs but has no text prefix on the `log` keyword, so fall back to a
// prefix-less logged rule there.
roundTripVariants(t, ctx, mgr, zone,
&Rule{Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "it"},
&Rule{Proto: TCP, Port: 22, Action: Accept, Log: true},
)
})
t.Run("ratelimit", func(t *testing.T) {
requireCap(t, caps.RateLimit)
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}})
})
t.Run("connlimit", func(t *testing.T) {
requireCap(t, caps.ConnLimit)
// Connection limiting splits across backends: nft expresses only a global
// cap; apf a per-source one that rejects the excess; csf a per-source one
// that drops it; pf a per-source one only on an accept rule (single inbound
// tcp port, no address). iptables does the global form. Try each and
// round-trip the first the backend accepts.
roundTripVariants(t, ctx, mgr, zone,
&Rule{Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}},
&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: true}},
&Rule{Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20, PerSource: true}},
&Rule{Proto: TCP, Port: 80, Action: Accept, ConnLimit: &ConnLimit{Count: 20, PerSource: true}},
)
})
t.Run("apfnativeconnlimit", func(t *testing.T) {
// apf writes a native connection limit — a single inbound tcp/udp port, no
// address, per-source, rejecting the excess — to conf.apf's CLIMIT lists
// rather than the hook (isConnLimitRule). apf now also routes every other
// connection-limit shape to the hook (needsHook), so the shared
// connlimit probe matches apf on its global hook form first; round-trip the
// native form here to keep the conf.apf CLIMIT path covered. Gated to apf.
if mgr.Type() != APFType {
t.Skip("apf-specific conf.apf CLIMIT path")
}
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
})
t.Run("comment", func(t *testing.T) {
requireCap(t, caps.Comments)
// Most backends carry a comment on a bare port rule; CSF and APF can only
// attach one to an address-based (IP-list) rule, so fall back to that
// form when the first probe succeeds but drops the comment.
variants := []*Rule{
{Proto: TCP, Port: 22, Action: Accept, Comment: "it-comment"},
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.0/24", Action: Accept, Comment: "it-comment"},
}
for _, rule := range variants {
rule := rule
require.NoError(t, mgr.AddRule(ctx, zone, rule))
got := findRule(t, ctx, mgr, zone, rule)
if got.Comment == rule.Comment {
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output), "rule still present after removal")
return
}
// Comment did not round-trip on this form; remove and try the next.
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
}
t.Fatal("comment did not round-trip on any probe form")
})
t.Run("priority", func(t *testing.T) {
requireCap(t, caps.Priority)
// A rule reads back carrying its priority, and priority is part of rule
// identity: an otherwise-identical rule at a different priority is a
// distinct rule, so a reconcile can actually change a rule's priority.
r := &Rule{Family: IPv4, Proto: TCP, Port: 5100, Action: Accept, Priority: 10}
require.NoError(t, mgr.AddRule(ctx, zone, r))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output),
"a rule must read back carrying its priority")
other := &Rule{Family: IPv4, Proto: TCP, Port: 5100, Action: Accept, Priority: 20}
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), other, mgr.Capabilities().Output),
"a rule differing only in priority must be a distinct rule")
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), "rule still present after removal")
})
// --- NAT ------------------------------------------------------------------
t.Run("nat", func(t *testing.T) {
requireCap(t, caps.NAT)
// Each entry is a kind with one or more forms; the first the backend accepts
// (not rejected with ErrUnsupportedNAT) is round-tripped. A kind no backend
// form accepts is skipped — e.g. pf has no standalone Redirect, and masquerade
// takes an interface on pf/nft/iptables but firewalld forbids one.
natVariants := [][]*NATRule{
{{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}},
{{Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080}},
{{Kind: SNAT, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"}},
{
{Kind: Masquerade, Interface: "eth1"},
{Kind: Masquerade},
},
}
for _, variants := range natVariants {
variants := variants
t.Run(variants[0].Kind.String(), func(t *testing.T) {
roundTripNATVariants(t, ctx, mgr, zone, variants)
})
}
})
t.Run("natmergedfamilyremove", func(t *testing.T) {
requireCap(t, caps.NAT)
// A v4 masquerade and its v6 twin on the same interface differ only in Family,
// so GetNATRules collapses the pair into one FamilyAny rule (mergeNATFamilies).
// Removing that read-back rule must clear BOTH underlying rows. Regression for
// a NAT remove that stopped at the first match and left the IPv6 twin loaded
// (pf), mirroring the filter-side mergedfamilyremove probe.
v4 := &NATRule{Kind: Masquerade, Family: IPv4, Interface: "eth1"}
v6 := &NATRule{Kind: Masquerade, Family: IPv6, Interface: "eth1"}
added := 0
for _, r := range []*NATRule{v4, v6} {
err := mgr.AddNATRule(ctx, zone, r)
if errors.Is(err, ErrUnsupportedNAT) {
continue // the backend cannot express this family of an interface masquerade.
}
require.NoError(t, err)
added++
}
if added < 2 {
t.Skip("backend does not express both families of an interface masquerade (no twin to merge)")
}
t.Cleanup(func() {
_ = mgr.RemoveNATRule(ctx, zone, v4)
_ = mgr.RemoveNATRule(ctx, zone, v6)
})
// Remove every masquerade the backend reports for this interface (one merged
// FamilyAny rule, or one per family), then confirm none remain.
isMasq := func(r *NATRule) bool { return r.Kind == Masquerade && r.Interface == "eth1" }
nats, err := mgr.GetNATRules(ctx, zone)
require.NoError(t, err)
for _, r := range nats {
if isMasq(r) {
require.NoError(t, mgr.RemoveNATRule(ctx, zone, r))
}
}
nats, err = mgr.GetNATRules(ctx, zone)
require.NoError(t, err)
for _, r := range nats {
require.False(t, isMasq(r), "masquerade still present after removal: %+v", r)
}
})
// --- rule ordering --------------------------------------------------------
t.Run("ordering", func(t *testing.T) {
requireCap(t, caps.RuleOrdering)
r1 := &Rule{Family: IPv4, Proto: TCP, Port: 3001, Action: Accept}
r2 := &Rule{Family: IPv4, Proto: TCP, Port: 3002, Action: Accept}
r3 := &Rule{Family: IPv4, Proto: TCP, Port: 3003, Action: Accept}
byPort := map[uint16]*Rule{3001: r1, 3002: r2, 3003: r3}
for _, r := range []*Rule{r1, r2, r3} {
require.NoError(t, mgr.AddRule(ctx, zone, r))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
}
// Whether AddRule appends or prepends is backend-specific (nft appends,
// iptables inserts at the top), so read the actual order rather than assume
// it, then verify MoveRule relocates a rule relative to that order.
order0 := managedPorts(t, ctx, mgr, zone, []uint16{3001, 3002, 3003})
require.Len(t, order0, 3)
// Move the currently-first rule to the end: it should land last while the
// other two keep their relative order.
require.NoError(t, mgr.MoveRule(ctx, zone, byPort[order0[0]], 3))
want := []uint16{order0[1], order0[2], order0[0]}
got := managedPorts(t, ctx, mgr, zone, []uint16{3001, 3002, 3003})
require.Equal(t, want, got, "MoveRule to the end should relocate the first rule")
// Insert a new rule at the front.
r0 := &Rule{Family: IPv4, Proto: TCP, Port: 3000, Action: Accept}
require.NoError(t, mgr.InsertRule(ctx, zone, 1, r0))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r0) })
ports := managedPorts(t, ctx, mgr, zone, []uint16{3000, 3001, 3002, 3003})
require.Equal(t, uint16(3000), ports[0], "InsertRule at position 1 should place the rule first")
// GetRules populates a 1-based Number that increases in chain order, so a
// caller can read a rule's position from the returned set.
requireAscendingNumbers(t, managedNumbers(t, ctx, mgr, zone, []uint16{3000, 3001, 3002, 3003}))
})
t.Run("mergedmove", func(t *testing.T) {
requireCap(t, caps.RuleOrdering)
// A v4/v6 twin collapses to one logical rule that occupies two physical rows.
// Moving it must relocate BOTH rows as a unit: on backends that map a merged
// position to a physical index, a naive move drags only one row and orphans a
// concrete-family twin (grows the chain, splits the merged rule).
const port = 3510
v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept}
v6 := &Rule{Family: IPv6, Proto: TCP, Port: port, Action: Accept}
added := 0
for _, r := range []*Rule{v4, v6} {
err := mgr.AddRule(ctx, zone, r)
if errors.Is(err, ErrUnsupported) {
continue
}
require.NoError(t, err)
added++
}
if added < 2 {
t.Skip("backend does not express both families of a bare-port accept (no twin to move)")
}
b := &Rule{Family: IPv4, Proto: TCP, Port: 3511, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, b))
t.Cleanup(func() {
_ = mgr.RemoveRule(ctx, zone, v4)
_ = mgr.RemoveRule(ctx, zone, v6)
_ = mgr.RemoveRule(ctx, zone, b)
})
// The pair merges, so the chain reads as two logical rules.
merged := findRule(t, ctx, mgr, zone, v4)
require.Equal(t, FamilyAny, merged.Family, "the v4/v6 pair should read back as one FamilyAny rule")
// Move the merged twin to the end (position 2, after b).
require.NoError(t, mgr.MoveRule(ctx, zone, merged, 2))
order := managedPorts(t, ctx, mgr, zone, []uint16{port, 3511})
require.Equal(t, []uint16{3511, port}, order,
"the merged twin must move as one unit to the end; an extra port entry means a concrete-family row was orphaned")
require.Equal(t, FamilyAny, findRule(t, ctx, mgr, zone, v4).Family,
"the moved twin must stay merged, not leave a concrete-family orphan")
})
t.Run("mergedinsertposition", func(t *testing.T) {
requireCap(t, caps.RuleOrdering)
// GetRules numbers logical (merged) rules, but the chain holds physical rows.
// Inserting before a rule's reported Number must land at that logical position:
// with merged pairs preceding the target, a naive position-1 against physical
// rows skews the placement by the number of absorbed twins.
a4 := &Rule{Family: IPv4, Proto: TCP, Port: 3520, Action: Accept}
a6 := &Rule{Family: IPv6, Proto: TCP, Port: 3520, Action: Accept}
b4 := &Rule{Family: IPv4, Proto: TCP, Port: 3521, Action: Accept}
b6 := &Rule{Family: IPv6, Proto: TCP, Port: 3521, Action: Accept}
added := 0
for _, r := range []*Rule{a4, a6, b4, b6} {
err := mgr.AddRule(ctx, zone, r)
if errors.Is(err, ErrUnsupported) {
continue
}
require.NoError(t, err)
added++
}
if added < 4 {
t.Skip("backend does not express both families of both bare-port pairs (no merged twins to skew placement)")
}
c := &Rule{Family: IPv4, Proto: TCP, Port: 3522, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, c))
t.Cleanup(func() {
for _, r := range []*Rule{a4, a6, b4, b6, c} {
_ = mgr.RemoveRule(ctx, zone, r)
}
})
// Two merged pairs plus c read back as three logical rules. Read c's Number
// (its logical position, whatever the backend's add order) and insert d there.
nums := managedNumbers(t, ctx, mgr, zone, []uint16{3522})
require.Len(t, nums, 1, "c should read back as one logical rule")
cNum := nums[0]
d := &Rule{Family: IPv4, Proto: TCP, Port: 3523, Action: Accept}
require.NoError(t, mgr.InsertRule(ctx, zone, cNum, d))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, d) })
// d must land immediately before c — not skewed early into the merged pairs.
order := managedPorts(t, ctx, mgr, zone, []uint16{3520, 3521, 3522, 3523})
require.Len(t, order, 4, "the two pairs, c and d must read back as four logical rules")
var ci int
for i, p := range order {
if p == 3522 {
ci = i
}
}
require.Greater(t, ci, 0, "d must be inserted before c, so c is not first")
require.Equal(t, uint16(3523), order[ci-1], "d must land immediately before c, after both merged pairs")
})
// --- NAT ordering ---------------------------------------------------------
t.Run("natordering", func(t *testing.T) {
requireCap(t, caps.NAT)
requireCap(t, caps.RuleOrdering)
// DNAT rules share the prerouting chain and differ only by matched port, so
// their read-back order reflects the requested positions.
n1 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 4001, ToAddress: "10.9.0.1", ToPort: 5001}
n2 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 4002, ToAddress: "10.9.0.2", ToPort: 5002}
for _, n := range []*NATRule{n1, n2} {
require.NoError(t, mgr.AddNATRule(ctx, zone, n))
t.Cleanup(func() { _ = mgr.RemoveNATRule(ctx, zone, n) })
}
// Insert a third DNAT rule at the front of the chain.
n0 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 4000, ToAddress: "10.9.0.0", ToPort: 5000}
require.NoError(t, mgr.InsertNATRule(ctx, zone, 1, n0))
t.Cleanup(func() { _ = mgr.RemoveNATRule(ctx, zone, n0) })
got := managedNATPorts(t, ctx, mgr, zone, []uint16{4000, 4001, 4002})
require.Equal(t, uint16(4000), got[0], "InsertNATRule at position 1 should place the rule first")
// GetNATRules populates a 1-based Number that increases in chain order.
requireAscendingNumbers(t, managedNATNumbers(t, ctx, mgr, zone, []uint16{4000, 4001, 4002}))
})
// --- default policy -------------------------------------------------------
t.Run("defaultpolicy", func(t *testing.T) {
requireCap(t, caps.DefaultPolicy)
orig, err := mgr.GetDefaultPolicy(ctx, zone)
require.NoError(t, err)
require.NotNil(t, orig)
// Restore whatever was there before, no matter how the assertions go.
t.Cleanup(func() { _ = mgr.SetDefaultPolicy(ctx, zone, orig) })
// Flip the input policy to the opposite of its current value and read back.
target := Drop
if orig.Input == Drop {
target = Accept
}
require.NoError(t, mgr.SetDefaultPolicy(ctx, zone, &DefaultPolicy{Input: target}))
got, err := mgr.GetDefaultPolicy(ctx, zone)
require.NoError(t, err)
require.Equal(t, target, got.Input, "input default policy should reflect the set value")
// Also exercise the forward direction on a backend that models one
// (firewalld reports only input, so it is left ActionInvalid and skipped).
if orig.Forward != ActionInvalid {
ftarget := Drop
if orig.Forward == Drop {
ftarget = Accept
}
require.NoError(t, mgr.SetDefaultPolicy(ctx, zone, &DefaultPolicy{Forward: ftarget}))
got, err := mgr.GetDefaultPolicy(ctx, zone)
require.NoError(t, err)
require.Equal(t, ftarget, got.Forward, "forward default policy should reflect the set value")
}
})
// A default policy set by one manager must survive a later mutation by a fresh
// manager instance (a process restart). nftables state outlives the process but a
// backend's per-instance "table ensured" flag does not, so a backend that
// re-declares its base chains on first use must not re-assert a policy and revert
// a configured default-drop — that would silently turn a default-deny firewall
// fail-open. Guards against the nft ensureTable regression.
t.Run("defaultpolicypersists", func(t *testing.T) {
requireCap(t, caps.DefaultPolicy)
orig, err := mgr.GetDefaultPolicy(ctx, zone)
require.NoError(t, err)
require.NotNil(t, orig)
t.Cleanup(func() { _ = mgr.SetDefaultPolicy(ctx, zone, orig) })
// Set input to the opposite of its current value, then reconcile via a fresh
// manager whose first act is a mutating call (which triggers any lazy
// table/chain setup).
target := Drop
if orig.Input == Drop {
target = Accept
}
require.NoError(t, mgr.SetDefaultPolicy(ctx, zone, &DefaultPolicy{Input: target}))
fresh, err := reconstruct(ctx)
require.NoError(t, err)
defer func() { _ = fresh.Close(ctx) }()
probe := &Rule{Proto: TCP, Port: 65510, Action: Accept}
require.NoError(t, fresh.AddRule(ctx, zone, probe))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, probe) })
got, err := fresh.GetDefaultPolicy(ctx, zone)
require.NoError(t, err)
require.Equal(t, target, got.Input,
"a mutating call on a fresh manager must not revert the configured input default policy")
})
// --- address sets ---------------------------------------------------------
t.Run("addresssets", func(t *testing.T) {
requireCap(t, caps.AddressSets)
set := &AddressSet{Name: integrationPrefix + "set", Family: IPv4, Type: SetHashIP}
require.NoError(t, mgr.AddAddressSet(ctx, set))
t.Cleanup(func() { _ = mgr.RemoveAddressSet(ctx, set.Name) })
require.NoError(t, mgr.AddAddressSetEntry(ctx, set.Name, "192.0.2.10"))
sets, err := mgr.GetAddressSets(ctx)
require.NoError(t, err)
got := findSet(sets, set.Name)
require.NotNil(t, got, "created set %q not found in %+v", set.Name, sets)
require.Contains(t, got.Entries, "192.0.2.10")
// A rule may match on the set by naming it in Source: a non-address token is
// translated to the backend's set-match syntax and round-trips.
setRule := &Rule{Family: IPv4, Proto: TCP, Port: 4200, Source: set.Name, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, setRule))
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), setRule, mgr.Capabilities().Output),
"rule matching on set %q not found", set.Name)
require.NoError(t, mgr.RemoveRule(ctx, zone, setRule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), setRule, mgr.Capabilities().Output),
"set-matching rule still present after removal")
require.NoError(t, mgr.RemoveAddressSetEntry(ctx, set.Name, "192.0.2.10"))
sets, err = mgr.GetAddressSets(ctx)
require.NoError(t, err)
if got = findSet(sets, set.Name); got != nil {
require.NotContains(t, got.Entries, "192.0.2.10", "entry should be gone after removal")
}
require.NoError(t, mgr.RemoveAddressSet(ctx, set.Name))
sets, err = mgr.GetAddressSets(ctx)
require.NoError(t, err)
require.Nil(t, findSet(sets, set.Name), "set should be gone after removal")
// A missing set is a well-defined not-found condition, not a generic
// error: GetAddressSet must report it, RemoveAddressSet/entry ops on it
// must not spuriously fail, and re-removing an already-gone set is a
// no-op rather than an error.
_, err = mgr.GetAddressSet(ctx, set.Name)
require.Error(t, err, "GetAddressSet on a nonexistent set must report not-found")
require.NoError(t, mgr.RemoveAddressSet(ctx, set.Name),
"removing an already-gone set must be a no-op")
// Removing a set that is still referenced by a live rule must not falsely
// report success. Either the backend removes the set (and it is gone) or it
// returns an error — it must never return nil while the set remains. (iptables'
// `ipset destroy` fails with "in use by a kernel component"; the backend must
// surface that rather than swallow it.)
inuse := &AddressSet{Name: integrationPrefix + "inuse", Family: IPv4, Type: SetHashIP}
require.NoError(t, mgr.AddAddressSet(ctx, inuse))
t.Cleanup(func() { _ = mgr.RemoveAddressSet(ctx, inuse.Name) })
ref := &Rule{Family: IPv4, Proto: TCP, Port: 4201, Source: inuse.Name, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, ref))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, ref) })
if err := mgr.RemoveAddressSet(ctx, inuse.Name); err == nil {
sets, err = mgr.GetAddressSets(ctx)
require.NoError(t, err)
require.Nil(t, findSet(sets, inuse.Name),
"RemoveAddressSet reported success while the referenced set is still present")
}
// With the referencing rule gone, the set removes cleanly.
require.NoError(t, mgr.RemoveRule(ctx, zone, ref))
require.NoError(t, mgr.RemoveAddressSet(ctx, inuse.Name))
})
// --- backup / restore -----------------------------------------------------
t.Run("backup", func(t *testing.T) {
r1 := &Rule{Proto: TCP, Port: 4001, Action: Accept}
r2 := &Rule{Proto: TCP, Port: 4002, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, r1))
require.NoError(t, mgr.AddRule(ctx, zone, r2))
t.Cleanup(func() {
_ = mgr.RemoveRule(ctx, zone, r1)
_ = mgr.RemoveRule(ctx, zone, r2)
})
backup, err := mgr.Backup(ctx, zone)
require.NoError(t, err)
require.NotNil(t, backup)
// Drop one rule, then restore and confirm it is back.
require.NoError(t, mgr.RemoveRule(ctx, zone, r1))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), r1, mgr.Capabilities().Output))
require.NoError(t, mgr.Restore(ctx, zone, backup))
rules := rulesOf(t, ctx, mgr, zone)
require.True(t, containsRule(rules, r1, mgr.Capabilities().Output), "restored rule r1 missing")
require.True(t, containsRule(rules, r2, mgr.Capabilities().Output), "restored rule r2 missing")
// Restore reconciles to the backup: a rule added after the snapshot (and so
// absent from it) must be removed, not left in place. This guards the ufw
// Restore that previously only re-touched the backup's own rules and left
// any current rule missing from the backup behind.
r3 := &Rule{Proto: TCP, Port: 4003, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, zone, r3))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r3) })
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r3, mgr.Capabilities().Output), "r3 should be present before reconcile restore")
require.NoError(t, mgr.Restore(ctx, zone, backup))
rules = rulesOf(t, ctx, mgr, zone)
require.True(t, containsRule(rules, r1, mgr.Capabilities().Output), "r1 missing after reconcile restore")
require.True(t, containsRule(rules, r2, mgr.Capabilities().Output), "r2 missing after reconcile restore")
require.False(t, containsRule(rules, r3, mgr.Capabilities().Output), "r3 was not in the backup and must be removed by Restore")
})
t.Run("restoreorder", func(t *testing.T) {
// Restore must reproduce the backed-up rule order on backends whose filter
// rules evaluate first-match in chain order. ufw's Restore re-added rules in
// forward order while AddRule prepends, silently reversing them — so a
// specific deny backed up above a broad allow came back below it and never
// fired. Compare each backend against itself: the order right after the adds
// must equal the order after a backup/remove/restore cycle.
if !orderedFilterBackend(mgr.Type()) {
t.Skip("backend has no first-match chain order to preserve")
}
ports := []uint16{4101, 4102, 4103}
var rules []*Rule
for _, p := range ports {
r := &Rule{Family: IPv4, Proto: TCP, Port: p, Action: Accept}
rules = append(rules, r)
require.NoError(t, mgr.AddRule(ctx, zone, r))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
}
before := managedPorts(t, ctx, mgr, zone, ports)
require.Len(t, before, 3, "all three rules should be present before backup")
backup, err := mgr.Backup(ctx, zone)
require.NoError(t, err)
for _, r := range rules {
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
}
require.NoError(t, mgr.Restore(ctx, zone, backup))
after := managedPorts(t, ctx, mgr, zone, ports)
require.Equal(t, before, after, "Restore must preserve the backed-up rule order")
})
t.Run("backupstate", func(t *testing.T) {
// Backup must capture — and Restore must reconstruct — more than filter/NAT
// rules: the default policy and the managed address sets. Regression for a
// Backup that dropped both, so a restore onto a fresh host lost a restrictive
// default policy and left a set-referencing rule dangling.
if !caps.AddressSets && !caps.DefaultPolicy {
t.Skip("backend captures neither an address set nor a default policy")
}
// Seed an address set (with an entry) so the backup has one to capture.
var set *AddressSet
if caps.AddressSets {
set = &AddressSet{Name: integrationPrefix + "bk", Family: IPv4, Type: SetHashNet, Entries: []string{"192.0.2.0/24"}}
require.NoError(t, mgr.AddAddressSet(ctx, set))
t.Cleanup(func() { _ = mgr.RemoveAddressSet(ctx, set.Name) })
}
// Set a known default policy (the opposite of the current input action, so the
// later assertion is meaningful) and remember the original to restore.
var policyTarget Action
if caps.DefaultPolicy {
orig, err := mgr.GetDefaultPolicy(ctx, zone)
require.NoError(t, err)
require.NotNil(t, orig)
t.Cleanup(func() { _ = mgr.SetDefaultPolicy(ctx, zone, orig) })
policyTarget = Drop
if orig.Input == Drop {
policyTarget = Accept
}
require.NoError(t, mgr.SetDefaultPolicy(ctx, zone, &DefaultPolicy{Input: policyTarget}))
}
backup, err := mgr.Backup(ctx, zone)
require.NoError(t, err)
require.NotNil(t, backup)
// Mutate the state away from the snapshot: delete the set, flip the policy.
if caps.AddressSets {
require.NotNil(t, findSet(backup.AddressSets, set.Name), "backup did not capture the address set")
require.NoError(t, mgr.RemoveAddressSet(ctx, set.Name))
}
if caps.DefaultPolicy {
require.NotNil(t, backup.DefaultPolicy, "backup did not capture the default policy")
flip := Accept
if policyTarget == Accept {
flip = Drop
}
require.NoError(t, mgr.SetDefaultPolicy(ctx, zone, &DefaultPolicy{Input: flip}))
}
// Restore must bring both back.
require.NoError(t, mgr.Restore(ctx, zone, backup))
if caps.AddressSets {
got, err := mgr.GetAddressSet(ctx, set.Name)
require.NoError(t, err, "restored address set missing")
require.ElementsMatch(t, set.Entries, got.Entries, "restored address set entries mismatch")
}
if caps.DefaultPolicy {
got, err := mgr.GetDefaultPolicy(ctx, zone)
require.NoError(t, err)
require.Equal(t, policyTarget, got.Input, "restored default input policy mismatch")
}
})
t.Run("profilescope", func(t *testing.T) {
// The Windows Filtering Platform maps a zone to a firewall profile; AddRule
// and GetRules scope to it, so RemoveRule must too. Removing a rule from one
// profile must not delete an identical rule in another. Only the wf backend
// models per-zone profiles this way; the others share one rule space.
if mgr.Type() != "windows-firewall" {
t.Skip("backend does not scope rules to per-zone profiles")
}
r := &Rule{Proto: TCP, Port: 5303, Action: Accept}
require.NoError(t, mgr.AddRule(ctx, "public", r))
require.NoError(t, mgr.AddRule(ctx, "private", r))
t.Cleanup(func() {
_ = mgr.RemoveRule(ctx, "public", r)
_ = mgr.RemoveRule(ctx, "private", r)
})
pub, err := mgr.GetRules(ctx, "public")
require.NoError(t, err)
require.True(t, containsRule(pub, r, mgr.Capabilities().Output), "rule should be present in the public profile")
priv, err := mgr.GetRules(ctx, "private")
require.NoError(t, err)
require.True(t, containsRule(priv, r, mgr.Capabilities().Output), "rule should be present in the private profile")
// Remove from the private profile only; the public copy must survive.
require.NoError(t, mgr.RemoveRule(ctx, "private", r))
priv, err = mgr.GetRules(ctx, "private")
require.NoError(t, err)
require.False(t, containsRule(priv, r, mgr.Capabilities().Output), "rule should be gone from the private profile")
pub, err = mgr.GetRules(ctx, "public")
require.NoError(t, err)
require.True(t, containsRule(pub, r, mgr.Capabilities().Output), "removing from the private profile must not delete the public rule")
})
t.Run("foreignmacsource", func(t *testing.T) {
// firewalld stores a MAC (or ipset) zone source, which GetRules reports as a
// bare-source rule. RemoveRule's source shortcut previously only handled an
// IP/CIDR, so a foreign MAC source could never be removed — Sync/Restore could
// not converge on it. Seed one out of band in the permanent config the backend
// reads and confirm the library both surfaces and removes it. Only firewalld
// models MAC/ipset zone sources this way.
if mgr.Type() != "firewalld" {
t.Skip("backend does not model MAC/ipset zone sources")
}
const mac = "00:11:22:33:44:55"
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-source="+mac).CombinedOutput(); err != nil {
t.Skipf("could not seed a foreign MAC source (firewall-cmd: %v): %s", err, out)
}
t.Cleanup(func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-source="+mac).Run()
})
want := &Rule{Source: mac, Action: Accept}
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
"a foreign MAC zone source should surface in GetRules")
require.NoError(t, mgr.RemoveRule(ctx, zone, want))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
"RemoveRule must remove a foreign MAC zone source")
})
t.Run("foreignprotocol", func(t *testing.T) {
// firewalld stores a bare-protocol allow (firewall-cmd --add-protocol) as a
// zone protocol entry, distinct from the rich-rule form the library writes.
// GetRules must surface it and RemoveRule must remove it, or a foreign
// --add-protocol is invisible to Sync/Restore. Only firewalld models this.
if mgr.Type() != "firewalld" {
t.Skip("backend does not model zone protocol entries")
}
const proto = "gre"
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-protocol="+proto).CombinedOutput(); err != nil {
t.Skipf("could not seed a foreign protocol (firewall-cmd: %v): %s", err, out)
}
t.Cleanup(func() {
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-protocol="+proto).Run()
})
want := &Rule{Proto: GRE, Action: Accept}
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
"a foreign zone protocol should surface in GetRules")
require.NoError(t, mgr.RemoveRule(ctx, zone, want))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), want, mgr.Capabilities().Output),
"RemoveRule must remove a foreign zone protocol")
})
t.Run("afterrulesexcluded", func(t *testing.T) {
// ufw writes and removes raw rules only in before.rules, so GetRules must not
// surface a rule from after.rules — otherwise Backup captures it and Restore
// re-adds it into before.rules, duplicating it. Only the ufw backend has this
// before/after split.
if mgr.Type() != "ufw" {
t.Skip("backend has no after.rules file")
}
const afterPath = "/etc/ufw/after.rules"
orig, err := os.ReadFile(afterPath)
if err != nil {
t.Skipf("cannot read after.rules: %v", err)
}
t.Cleanup(func() { _ = os.WriteFile(afterPath, orig, 0o640) })
// Inject a parseable rule into the ufw-after-input chain, before the real
// COMMIT directive. after.rules carries a "# don't delete the 'COMMIT' line"
// comment, so match the standalone COMMIT line rather than the first literal.
lines := strings.Split(string(orig), "\n")
placed := false
for i, l := range lines {
if strings.TrimSpace(l) == "COMMIT" {
lines = append(lines[:i:i], append([]string{"-A ufw-after-input -p tcp -m tcp --dport 8765 -j ACCEPT"}, lines[i:]...)...)
placed = true
break
}
}
require.True(t, placed, "after.rules should have a COMMIT directive to inject before")
require.NoError(t, os.WriteFile(afterPath, []byte(strings.Join(lines, "\n")), 0o640))
probe := &Rule{Family: IPv4, Proto: TCP, Port: 8765, Action: Accept}
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), probe, mgr.Capabilities().Output),
"a rule in after.rules must not be surfaced by GetRules")
})
}
// orderedFilterBackend reports whether a backend's filter rules form a first-match
// chain whose order Restore must reproduce. The list/zone-model backends (csf, apf,
// firewalld, wf) do not order rules this way, so the restoreorder check is skipped
// for them.
func orderedFilterBackend(typ string) bool {
switch typ {
case "nftables", "iptables", "ufw", "pf":
return true
}
return false
}
// --- helpers ----------------------------------------------------------------
// requireCap skips the current subtest when the backend does not advertise the
// feature under test.
func requireCap(t *testing.T, supported bool) {
t.Helper()
if !supported {
t.Skip("feature not supported by this backend")
}
}
// roundTripRule adds a rule, confirms it reads back, removes it, and confirms it
// is gone. A t.Cleanup guards against a mid-test failure leaving the rule behind.
func roundTripRule(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
t.Helper()
require.NoError(t, mgr.AddRule(ctx, zone, rule))
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
rules := rulesOf(t, ctx, mgr, zone)
require.True(t, containsRule(rules, rule, mgr.Capabilities().Output), "added rule %+v not found in %s", rule, dumpRules(rules))
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output), "rule still present after removal")
}
// roundTripVariants tries each rule form in order and round-trips the first one
// the backend accepts, skipping a form the backend rejects with an ErrUnsupported
// sentinel. It lets a single probe cover backends that express the same
// capability in different forms — e.g. nft matches a global connection limit while
// apf only expresses a per-source one, and nft matches bare ICMP while apf
// requires a type. It fails if the backend rejects every form as unsupported,
// since the capability under test claimed the feature works.
func roundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) {
t.Helper()
for _, rule := range variants {
err := mgr.AddRule(ctx, zone, rule)
if errors.Is(err, ErrUnsupported) {
continue // the backend cannot express this form; try the next.
}
require.NoError(t, err)
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
rules := rulesOf(t, ctx, mgr, zone)
require.True(t, containsRule(rules, rule, mgr.Capabilities().Output), "added rule %+v not found in %s", rule, dumpRules(rules))
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output), "rule still present after removal")
return
}
t.Fatal("backend advertised the capability but rejected every probe form as unsupported")
}
// roundTripNATVariants tries each NAT rule form and round-trips the first the
// backend accepts (not rejected with ErrUnsupportedNAT), mirroring
// roundTripVariants for filter rules. It skips the kind when the backend accepts
// no form (e.g. pf has no standalone Redirect).
func roundTripNATVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants []*NATRule) {
t.Helper()
for _, nat := range variants {
err := mgr.AddNATRule(ctx, zone, nat)
if errors.Is(err, ErrUnsupportedNAT) {
continue
}
require.NoError(t, err)
t.Cleanup(func() { _ = mgr.RemoveNATRule(ctx, zone, nat) })
rules, err := mgr.GetNATRules(ctx, zone)
require.NoError(t, err)
require.True(t, containsNAT(rules, nat), "added NAT rule %+v not found in %+v", nat, rules)
require.NoError(t, mgr.RemoveNATRule(ctx, zone, nat))
rules, err = mgr.GetNATRules(ctx, zone)
require.NoError(t, err)
require.False(t, containsNAT(rules, nat), "NAT rule still present after removal")
return
}
t.Skipf("%s does not support the %s NAT kind in any probed form", mgr.Type(), variants[0].Kind)
}
// rulesOf reads the managed rules or fails the test.
func rulesOf(t *testing.T, ctx context.Context, mgr Manager, zone string) []*Rule {
t.Helper()
rules, err := mgr.GetRules(ctx, zone)
require.NoError(t, err)
return rules
}
// containsRule reports whether want appears in rules, compared family-agnostically
// (EqualBase) so a FamilyAny rule matches a backend that stored it under a concrete
// family, and vice versa. A DirAny read-back rule also satisfies a concrete-
// direction want: a backend whose config already covers the opposite direction
// (e.g. apf's default egress ICMP list) collapses a concrete-direction add into one
// DirAny rule, and the added rule is still present as one direction of it.
func containsRule(rules []*Rule, want *Rule, outputHonored bool) bool {
for _, r := range rules {
if r.EqualBase(want, outputHonored) {
return true
}
if outputHonored && r.Direction == DirAny &&
(want.Direction == DirInput || want.Direction == DirOutput) &&
r.canonicalMatch().EqualBase(want.canonicalMatch(), false) {
return true
}
}
return false
}
// findRule returns the first managed rule matching want, failing if none do.
func findRule(t *testing.T, ctx context.Context, mgr Manager, zone string, want *Rule) *Rule {
t.Helper()
for _, r := range rulesOf(t, ctx, mgr, zone) {
if r.EqualBase(want, mgr.Capabilities().Output) {
return r
}
}
t.Fatalf("rule %+v not found", want)
return nil
}
// managedPorts returns, in backend order, the destination ports of the managed
// rules whose port is in the wanted set. It lets ordering assertions ignore any
// unrelated rules that share the zone.
func managedPorts(t *testing.T, ctx context.Context, mgr Manager, zone string, wanted []uint16) []uint16 {
t.Helper()
want := make(map[uint16]bool, len(wanted))
for _, p := range wanted {
want[p] = true
}
var out []uint16
for _, r := range rulesOf(t, ctx, mgr, zone) {
if want[r.Port] {
out = append(out, r.Port)
}
}
return out
}
// managedNATPorts returns, in backend order, the matched ports of the NAT rules
// whose port is in the wanted set, mirroring managedPorts for NAT ordering.
func managedNATPorts(t *testing.T, ctx context.Context, mgr Manager, zone string, wanted []uint16) []uint16 {
t.Helper()
want := make(map[uint16]bool, len(wanted))
for _, p := range wanted {
want[p] = true
}
rules, err := mgr.GetNATRules(ctx, zone)
require.NoError(t, err)
var out []uint16
for _, r := range rules {
if want[r.Port] {
out = append(out, r.Port)
}
}
return out
}
// managedNumbers returns, in backend order, the Number of each managed rule whose
// port is in the wanted set, so an ordering assertion can check that GetRules
// populated a rule's position.
func managedNumbers(t *testing.T, ctx context.Context, mgr Manager, zone string, wanted []uint16) []int {
t.Helper()
want := make(map[uint16]bool, len(wanted))
for _, p := range wanted {
want[p] = true
}
var out []int
for _, r := range rulesOf(t, ctx, mgr, zone) {
if want[r.Port] {
out = append(out, r.Number)
}
}
return out
}
// managedNATNumbers is managedNumbers for NAT rules.
func managedNATNumbers(t *testing.T, ctx context.Context, mgr Manager, zone string, wanted []uint16) []int {
t.Helper()
want := make(map[uint16]bool, len(wanted))
for _, p := range wanted {
want[p] = true
}
rules, err := mgr.GetNATRules(ctx, zone)
require.NoError(t, err)
var out []int
for _, r := range rules {
if want[r.Port] {
out = append(out, r.Number)
}
}
return out
}
// requireAscendingNumbers asserts every number is non-zero (an ordered backend
// populates Number) and strictly increases in the given order.
func requireAscendingNumbers(t *testing.T, nums []int) {
t.Helper()
require.NotEmpty(t, nums)
for i, num := range nums {
require.NotZero(t, num, "an ordered backend must populate a rule's Number")
if i > 0 {
require.Greater(t, num, nums[i-1], "Number must increase in chain order")
}
}
}
// containsNAT reports whether want appears in rules (family-agnostic).
func containsNAT(rules []*NATRule, want *NATRule) bool {
for _, r := range rules {
if r.EqualBase(want) {
return true
}
}
return false
}
// dumpRules renders rules as readable multi-line %+v for failure messages.
func dumpRules(rules []*Rule) string {
if len(rules) == 0 {
return "[] (no managed rules)"
}
out := ""
for _, r := range rules {
out += fmt.Sprintf("\n %+v", r)
}
return out
}
// findSet returns the address set with the given name, or nil.
func findSet(sets []*AddressSet, name string) *AddressSet {
for _, s := range sets {
if s.Name == name {
return s
}
}
return nil
}