1848 lines
79 KiB
Go
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
|
|
}
|