- New Capabilities: PortPair, Negation, RejectAction, FamilyWithoutAddress, DenyActionFromConfig, advertised per backend. - coversDirection isolates DirForward even when output is unowned; add splitNATDualRow so a concrete-family removal re-adds the opposite family's NAT translation. - Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the "invalid" sentinel as input while JSON round-trips it. - Sync counts additions on mid-batch failure and uses RuleBatcher. - NewManager runs a probe loop joining each backend's reason for diagnosability; services.go drops "generated" from enabled, handles it on enable, clears start-limit-hit on restart, and matches rc.local by token. - nftables: per-source connection limits (meter set), quoted-token parsing preserving log-prefix spacing, digit-led prefix sanitizing. - apf/csf: deny-action-from-config with cached STOP settings, port lists and inexpressible shapes routed through the pre-hook, confKeyApplies guard against a missing config line. - atomic config writes fsync before rename and resolve symlinks; readConfValue is last-assignment-wins; runCommand preserves the exit code through the wrapped error. - Move coreos/go-systemd to the maintained v22 module directly.
2341 lines
101 KiB
Go
2341 lines
101 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"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// integrationPrefix namespaces every rule, set and table this suite creates so
|
|
// its writes are distinguishable from anything else on the box and, combined with
|
|
// the isolated container netns, are safe to clean up.
|
|
const integrationPrefix = "gofwit"
|
|
|
|
// backendFactory pairs a backend's name with its constructor so a platform's
|
|
// TestIntegration can build them from a single table.
|
|
type backendFactory struct {
|
|
name string
|
|
new func(ctx context.Context, rulePrefix string) (Manager, error)
|
|
}
|
|
|
|
// foreignSeed is a rule planted out of band with a backend's own tooling, standing
|
|
// in for a rule a human operator added, plus what the library should observe for
|
|
// it. Each platform's foreignSeeder produces one; see integration_linux_test.go.
|
|
type foreignSeed struct {
|
|
// rule is what GetRules should report for the seeded rule.
|
|
rule *Rule
|
|
// hasPrefix is what the informational HasPrefix flag should read: false on the
|
|
// tag-based backends (no library comment/tag on the seeded rule) and for a
|
|
// foreign nft table, true on firewalld, whose container is the zone itself, so
|
|
// every rule read from it carries the flag.
|
|
hasPrefix bool
|
|
// inScope reports whether the backend's mutations reach the seeded rule.
|
|
// Everything but a foreign nft table is in scope; nft reports foreign tables
|
|
// but scopes its writes to its own, so RemoveRule must no-op there without
|
|
// error (Sync relies on exactly that).
|
|
inScope bool
|
|
// undo unseeds, best effort — the suite's RemoveRule normally already has.
|
|
undo func()
|
|
}
|
|
|
|
// runIntegration constructs each backend in backends and runs the capability suite
|
|
// against it. It honors FIREWALL_BACKEND: when set, only that backend runs and a
|
|
// construction failure is fatal (the environment is expected to provide it); when
|
|
// unset, backends that fail to construct are skipped. Each platform's
|
|
// TestIntegration (in the per-OS integration_<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("foreignrule", func(t *testing.T) {
|
|
// The library manages the ACTUAL firewall, not only its own rules: a rule
|
|
// created out of band with the backend's own tooling must surface in
|
|
// GetRules and be removable with RemoveRule, or Sync/Backup/Restore could
|
|
// never converge on real firewall state. Each platform provides a seeder
|
|
// that plants a rule the way an operator would (foreignSeeder); a backend
|
|
// with no seeder skips.
|
|
seeder := foreignSeeder(mgr)
|
|
if seeder == nil {
|
|
t.Skip("no out-of-band seeder for this backend")
|
|
}
|
|
seed, err := seeder(zone)
|
|
if err != nil {
|
|
t.Skipf("could not seed a foreign rule: %v", err)
|
|
}
|
|
t.Cleanup(seed.undo)
|
|
|
|
rules := rulesOf(t, ctx, mgr, zone)
|
|
require.True(t, containsRule(rules, seed.rule, caps.Output),
|
|
"a rule seeded out of band must surface in GetRules, got %s", dumpRules(rules))
|
|
got := findRule(t, ctx, mgr, zone, seed.rule)
|
|
require.Equal(t, seed.hasPrefix, got.HasPrefix,
|
|
"HasPrefix must reflect how the backend namespaces its rules (informational only, never an ownership filter)")
|
|
|
|
if !seed.inScope {
|
|
// Reported but outside the backend's mutation scope (a foreign nft
|
|
// table): RemoveRule must no-op without error and leave the rule alone,
|
|
// or Sync would fail hard on every reconcile that sees it.
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule),
|
|
"removing a reported out-of-scope rule must no-op, not error")
|
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, caps.Output),
|
|
"an out-of-scope foreign rule must survive the no-op removal")
|
|
return
|
|
}
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule),
|
|
"a foreign rule is managed like any other and must be removable")
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, caps.Output),
|
|
"foreign rule still present after removal")
|
|
})
|
|
|
|
t.Run("output", func(t *testing.T) {
|
|
requireCap(t, caps.Output)
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Direction: DirOutput, Proto: TCP, Port: 8080, Action: Accept})
|
|
})
|
|
|
|
t.Run("forward", func(t *testing.T) {
|
|
requireCap(t, caps.Forward)
|
|
// A classic routed-traffic filter: allow forwarding TCP from one network to
|
|
// another through the host. It binds no interface, so it does not depend on
|
|
// the test host having a particular NIC.
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{
|
|
Direction: DirForward, Family: IPv4, Proto: TCP, Port: 8080,
|
|
Source: "192.0.2.0/24", Destination: "198.51.100.0/24", Action: Accept,
|
|
})
|
|
})
|
|
|
|
t.Run("hostaddress", func(t *testing.T) {
|
|
// A rule matching a single host address, written with an explicit /32.
|
|
// Backends re-spell an address on read — nft and ufw strip the /32,
|
|
// firewalld requires and stores a family, iptables-save adds the /32 — so
|
|
// this is where a rule silently fails to read back or reconcile. Left
|
|
// FamilyAny so the backend must resolve the family from the address itself.
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "192.0.2.10/32", Action: Accept})
|
|
})
|
|
|
|
t.Run("hostnoport", func(t *testing.T) {
|
|
// Portless host shapes. csf/apf trust files store a single all-protocol
|
|
// address and their advanced rule holds a single port, so a portless
|
|
// concrete-protocol host, a source+destination pair, and (for apf) a
|
|
// multi-port list have no trust-file form there and route to the
|
|
// raw-iptables hook (shapeNeedsHook / needsHook), while csf expresses a
|
|
// multi-port list natively as a comma list. The chain backends express the
|
|
// same shapes natively. Every backend runs every shape so a bug in
|
|
// whichever path expresses it shows up; a backend that cannot express a
|
|
// shape at all skips via the ErrUnsupported sentinel.
|
|
t.Run("protohost", func(t *testing.T) {
|
|
// A concrete-protocol host with no port.
|
|
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Proto: TCP, Source: "192.0.2.40/32", Action: Accept})
|
|
})
|
|
t.Run("srcdstpair", func(t *testing.T) {
|
|
// A source+destination pair with no port.
|
|
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Source: "192.0.2.41/32", Destination: "192.0.2.42/32", Action: Accept})
|
|
})
|
|
t.Run("multiportdeny", func(t *testing.T) {
|
|
// A multi-port list as a deny: apf writes it to the hook with its literal
|
|
// action, while csf writes it to csf.deny, whose action follows csf.conf
|
|
// (stock DROP). Probe both actions and round-trip the first the backend
|
|
// accepts, mirroring the denyaddress case.
|
|
roundTripVariantsOrSkip(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},
|
|
)
|
|
})
|
|
t.Run("multiporthost", func(t *testing.T) {
|
|
// 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.
|
|
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 6001, End: 6001}, {Start: 6002, End: 6002}}, Source: "192.0.2.43/32", Action: Accept})
|
|
})
|
|
})
|
|
|
|
t.Run("matchcombos", func(t *testing.T) {
|
|
// Combined matches that overflow a csf/apf advanced line — it holds exactly
|
|
// one address field and one port-flow field — so both route them to their
|
|
// raw-iptables hook (shapeNeedsHook); the chain backends match -s with -d
|
|
// and --sport with --dport directly. Every backend runs every combo; the
|
|
// only capability in play is PortPair (firewalld's rich rules carry a
|
|
// single port element). Port 22 is avoided in every field so an SSH-driven
|
|
// run keeps its session.
|
|
t.Run("portedpair", func(t *testing.T) {
|
|
// A source+destination pair carrying a destination port: the second address
|
|
// overflows the advanced line, so on csf/apf this is the shape that used to
|
|
// reach MarshalAdvRule and fail. Every backend expresses it; no capability
|
|
// gates it, so the round trip is unconditional.
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8443, Source: "192.0.2.50/32", Destination: "198.51.100.50/32", Action: Accept})
|
|
})
|
|
t.Run("sourceportpair", func(t *testing.T) {
|
|
// The same pair on a source port.
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: UDP, SourcePort: 5353, Source: "192.0.2.51/32", Destination: "198.51.100.51/32", Action: Accept})
|
|
})
|
|
t.Run("icmppair", func(t *testing.T) {
|
|
// The same pair on a typed ICMP match.
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.52/32", Destination: "198.51.100.52/32", Action: Accept})
|
|
})
|
|
t.Run("bothports", func(t *testing.T) {
|
|
// A source port matched together with a destination port, with an
|
|
// address. firewalld advertises PortPair false (a rich rule carries a
|
|
// single port element) and is gated out; everyone else must express it.
|
|
requireCap(t, caps.PortPair)
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8444, SourcePort: 5354, Source: "192.0.2.53/32", Action: Accept})
|
|
})
|
|
t.Run("bothportsbare", func(t *testing.T) {
|
|
// The same source+destination port match without an address.
|
|
requireCap(t, caps.PortPair)
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8445, SourcePort: 5355, Action: Accept})
|
|
})
|
|
})
|
|
|
|
t.Run("negatedaddress", func(t *testing.T) {
|
|
requireCap(t, caps.Negation)
|
|
// A negated source must round-trip through whatever path expresses it:
|
|
// iptables/nft/pf/firewalld negate natively, ufw's tuple grammar cannot so
|
|
// it reroutes to the before.rules raw path (iptables `! -s`), and csf/apf
|
|
// route it to their raw-iptables hook. wf advertises Negation false (WFP
|
|
// has no negated address condition) and is gated out; everyone else must
|
|
// express it.
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Source: "!192.0.2.50/32", Action: Accept})
|
|
})
|
|
|
|
t.Run("denyaddress", func(t *testing.T) {
|
|
// A deny rule carrying a host address exercises the deny-list address path
|
|
// (csf.deny / apf deny_hosts, and the reject chains elsewhere). Reject is
|
|
// used because the address-list backends canonicalize a deny to it; Windows
|
|
// Filtering Platform has no reject action and falls back to Drop.
|
|
roundTripVariants(t, ctx, mgr, zone,
|
|
&Rule{Family: IPv4, Proto: TCP, Port: 3389, Source: "192.0.2.20/32", Action: Reject},
|
|
&Rule{Family: IPv4, Proto: TCP, Port: 3389, Source: "192.0.2.20/32", Action: Drop},
|
|
)
|
|
})
|
|
|
|
t.Run("denyexactaction", func(t *testing.T) {
|
|
// A deny must read back with the exact action it was added with, or a
|
|
// caller managing it sees churn on every Sync (read back as one action,
|
|
// never equal to the desired other). The stakes are highest on csf/apf,
|
|
// whose deny stores encode no action of their own — the tool applies an
|
|
// action taken from config (csf.conf DROP / conf.apf ALL_STOP, stock
|
|
// default DROP), so a deny whose action matches config is stored natively
|
|
// and one that differs is injected through their pre-hook rather than
|
|
// refused — but the property holds for every backend, so both actions run
|
|
// everywhere. A backend with no reject action at all (wf) advertises
|
|
// RejectAction false and skips that action by capability.
|
|
for _, tc := range []struct {
|
|
name string
|
|
deny *Rule
|
|
}{
|
|
{"drop", &Rule{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.30/32", Action: Drop}},
|
|
{"reject", &Rule{Family: IPv4, Proto: TCP, Port: 3390, Source: "192.0.2.31/32", Action: Reject}},
|
|
} {
|
|
deny := tc.deny
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if deny.Action == Reject {
|
|
requireCap(t, caps.RejectAction)
|
|
}
|
|
require.NoError(t, mgr.AddRule(ctx, zone, deny))
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, deny) })
|
|
|
|
// The rule must read back with its exact action so it compares equal to the
|
|
// desired rule and Sync leaves it in place rather than churning it.
|
|
got := findRule(t, ctx, mgr, zone, deny)
|
|
require.Equal(t, deny.Action, got.Action,
|
|
"a deny must read back with its exact action, not a config default: %+v", deny)
|
|
require.True(t, got.Equal(deny, mgr.Capabilities().Output),
|
|
"the read-back deny must equal the desired rule so Sync does not churn it: %+v", deny)
|
|
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, deny))
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), deny, mgr.Capabilities().Output),
|
|
"deny rule still present after removal: %+v", deny)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("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.
|
|
plant := hookPlanter(mgr)
|
|
if plant == nil {
|
|
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("familypairremove", func(t *testing.T) {
|
|
// A v4 rule and its v6 twin are two rows on most backends. Removing every rule
|
|
// the backend reports must clear them all. Regression for a family-strict
|
|
// remove that could not match a family-agnostic rule at all (a silent no-op
|
|
// that left the port open) or that removed only one of the two rows. Gated
|
|
// on FamilyWithoutAddress (the probe rules are family-pinned bare ports);
|
|
// the per-family sentinel-continue below covers only the environment case
|
|
// of a backend whose own IPv6 handling is off (csf/apf).
|
|
requireCap(t, caps.FamilyWithoutAddress)
|
|
const port = 3492
|
|
v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept}
|
|
v6 := &Rule{Family: IPv6, Proto: TCP, Port: port, Action: Accept}
|
|
added := 0
|
|
for _, r := range []*Rule{v4, v6} {
|
|
err := mgr.AddRule(ctx, zone, r)
|
|
if errors.Is(err, ErrUnsupported) {
|
|
continue // the backend cannot express this family of a bare-port accept.
|
|
}
|
|
require.NoError(t, err)
|
|
added++
|
|
}
|
|
if added == 0 {
|
|
t.Skip("backend expresses neither family of a bare-port accept")
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = mgr.RemoveRule(ctx, zone, v4)
|
|
_ = mgr.RemoveRule(ctx, zone, v6)
|
|
})
|
|
|
|
portRule := func(r *Rule) bool { return r.Proto == TCP && r.Port == port }
|
|
|
|
// With both families expressible, GetRules must report coverage for BOTH. A
|
|
// cross-family dedup that matches on a family-agnostic Equal would drop the
|
|
// v6 add and leave IPv6 unprotected; a concrete-family removal must then clear
|
|
// only its own family and leave the twin in place.
|
|
if added == 2 {
|
|
familyCoverage := func() (v4Cov, v6Cov bool) {
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if !portRule(r) {
|
|
continue
|
|
}
|
|
switch r.Family {
|
|
case FamilyAny:
|
|
v4Cov, v6Cov = true, true
|
|
case IPv4:
|
|
v4Cov = true
|
|
case IPv6:
|
|
v6Cov = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
has4, has6 := familyCoverage()
|
|
require.True(t, has4 && has6,
|
|
"both families must be present after adding the v4/v6 pair; a missing family means a cross-family add was silently dropped")
|
|
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, v4))
|
|
has4, has6 = familyCoverage()
|
|
require.True(t, has6, "removing the v4 twin must leave the v6 twin in place")
|
|
require.False(t, has4, "removing the v4 twin must not leave a v4 row behind")
|
|
|
|
// Restore the pair so the remove-all phase below starts from both rows.
|
|
require.NoError(t, mgr.AddRule(ctx, zone, v4))
|
|
}
|
|
|
|
// Remove each rule the backend reports for this port (one family-agnostic rule,
|
|
// or one per family), then confirm none remain — a removal must not leave a
|
|
// twin row behind.
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if portRule(r) {
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
|
|
}
|
|
}
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
require.False(t, portRule(r), "port %d rule still present after removal: %+v", port, r)
|
|
}
|
|
})
|
|
|
|
t.Run("familyanysplitremove", func(t *testing.T) {
|
|
// Unlike familypairremove (which adds a v4/v6 PAIR), this adds ONE FamilyAny
|
|
// rule. A FamilyAny rule with no address is stored as a single dual-family
|
|
// object by the unified backends (nft inet, pf without af, firewalld's
|
|
// dual-stack zone entries) and as one row per family by the separated backends;
|
|
// either way GetRules reports coverage for both families. Removing a single
|
|
// family must leave the other in place — the backend splits the dual object
|
|
// (apf splits its dual-stack port list through the raw-iptables hook). The
|
|
// split is gated on FamilyWithoutAddress: wf's address-less filters cannot
|
|
// pin one family, so it advertises the capability false. 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. A single-family removal implies
|
|
// expressing a single-family rule of the shape, so the split is gated on
|
|
// FamilyWithoutAddress (wf cannot pin a family without an address).
|
|
runSplit := func(t *testing.T, c splitCase) {
|
|
requireCap(t, caps.FamilyWithoutAddress)
|
|
coverage := func() (v4Cov, v6Cov bool) {
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if !c.match(r) {
|
|
continue
|
|
}
|
|
switch r.Family {
|
|
case FamilyAny:
|
|
v4Cov, v6Cov = true, true
|
|
case IPv4:
|
|
v4Cov = true
|
|
case IPv6:
|
|
v6Cov = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
clear := func() {
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if c.match(r) {
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
|
|
}
|
|
}
|
|
}
|
|
|
|
// The backend must express this FamilyAny shape with dual coverage; skip
|
|
// where it cannot (csf/apf need an address, etc.).
|
|
if err := mgr.AddRule(ctx, zone, c.anyRule); errors.Is(err, ErrUnsupported) {
|
|
t.Skip("backend cannot express this bare FamilyAny shape")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
t.Cleanup(clear)
|
|
if has4, has6 := coverage(); !(has4 && has6) {
|
|
clear()
|
|
t.Skipf("backend does not give this FamilyAny shape dual coverage (v4=%v v6=%v)", has4, has6)
|
|
}
|
|
|
|
// Remove IPv4; IPv6 must survive. FamilyWithoutAddress is required
|
|
// above, so a single-family removal of the shape must be expressible.
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, c.v4))
|
|
has4, has6 := coverage()
|
|
require.False(t, has4, "removing IPv4 must clear IPv4 coverage")
|
|
require.True(t, has6, "removing IPv4 from a FamilyAny rule must leave IPv6 in place")
|
|
|
|
// Opposite direction from a clean slate: remove IPv6, IPv4 must survive.
|
|
clear()
|
|
require.NoError(t, mgr.AddRule(ctx, zone, c.anyRule))
|
|
if has4, has6 := coverage(); !(has4 && has6) {
|
|
t.Fatalf("re-adding the FamilyAny rule must restore both families (v4=%v v6=%v)", has4, has6)
|
|
}
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, c.v6))
|
|
has4, has6 = coverage()
|
|
require.False(t, has6, "removing IPv6 must clear IPv6 coverage")
|
|
require.True(t, has4, "removing IPv6 from a FamilyAny rule must leave IPv4 in place")
|
|
}
|
|
|
|
t.Run("destport", func(t *testing.T) {
|
|
const p uint16 = 3493
|
|
runSplit(t, splitCase{
|
|
anyRule: &Rule{Family: FamilyAny, Proto: TCP, Port: p, Action: Accept},
|
|
v4: &Rule{Family: IPv4, Proto: TCP, Port: p, Action: Accept},
|
|
v6: &Rule{Family: IPv6, Proto: TCP, Port: p, Action: Accept},
|
|
match: func(r *Rule) bool {
|
|
s := r.PortSpecs()
|
|
return r.Proto == TCP && len(s) == 1 && s[0].Start == p && !r.HasSourcePorts()
|
|
},
|
|
})
|
|
})
|
|
|
|
t.Run("sourceport", func(t *testing.T) {
|
|
const p uint16 = 3494
|
|
runSplit(t, splitCase{
|
|
anyRule: &Rule{Family: FamilyAny, Proto: TCP, SourcePort: p, Action: Accept},
|
|
v4: &Rule{Family: IPv4, Proto: TCP, SourcePort: p, Action: Accept},
|
|
v6: &Rule{Family: IPv6, Proto: TCP, SourcePort: p, Action: Accept},
|
|
match: func(r *Rule) bool {
|
|
s := r.SourcePortSpecs()
|
|
return r.Proto == TCP && len(s) == 1 && s[0].Start == p && !r.HasPorts()
|
|
},
|
|
})
|
|
})
|
|
|
|
t.Run("ordering", func(t *testing.T) {
|
|
requireCap(t, caps.RuleOrdering)
|
|
// On an ordered backend the surviving family must keep the dual rule's place
|
|
// in the chain, not jump to the end after the split re-adds it. AddRule order
|
|
// is backend-specific (nft appends, iptables prepends), so read the actual
|
|
// order rather than assume it, then assert the split leaves it unchanged. The
|
|
// split removes IPv6 and every probe rule's survivor is IPv4, so a
|
|
// family-separated backend (iptables/ufw read their v4 chain first) keeps the
|
|
// survivor in its slot too — no false failure there.
|
|
const before, dual, after uint16 = 3496, 3497, 3498
|
|
rBefore := &Rule{Family: IPv4, Proto: TCP, Port: before, Action: Accept}
|
|
rDual := &Rule{Family: FamilyAny, Proto: TCP, Port: dual, Action: Accept}
|
|
rAfter := &Rule{Family: IPv4, Proto: TCP, Port: after, Action: Accept}
|
|
for _, r := range []*Rule{rBefore, rDual, rAfter} {
|
|
if err := mgr.AddRule(ctx, zone, r); errors.Is(err, ErrUnsupported) {
|
|
t.Skip("backend cannot express one of the ordering probe rules")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
|
|
}
|
|
// A family-separated backend stores the dual rule as a v4 row and a v6 row, so
|
|
// read the order of the rows that survive the split — everything but IPv6 —
|
|
// and require the split to leave exactly that sequence in place.
|
|
ports := map[uint16]bool{before: true, dual: true, after: true}
|
|
survivingOrder := func() []uint16 {
|
|
var out []uint16
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if ports[r.Port] && r.impliedFamily() != IPv6 {
|
|
out = append(out, r.Port)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
order0 := survivingOrder()
|
|
require.Len(t, order0, 3, "all three probe rules should be present before the split")
|
|
|
|
// Split off IPv6; the surviving IPv4 row must keep the dual rule's position.
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, &Rule{Family: IPv6, Proto: TCP, Port: dual, Action: Accept}))
|
|
require.Equal(t, order0, survivingOrder(),
|
|
"the re-added surviving family must keep the dual rule's position")
|
|
})
|
|
})
|
|
|
|
t.Run("tcpudproundtrip", func(t *testing.T) {
|
|
// A TCPUDP rule matches both transports. Backends with no both-transports form
|
|
// (iptables, pf, firewalld, wf, csf/apf's per-transport config lists) store it
|
|
// as a tcp row plus a udp row; nftables stores it as one `meta l4proto
|
|
// { tcp, udp }` row; ufw as one any-protocol tuple. GetRules reports whichever
|
|
// rows the backend actually holds, so the read-back is checked by coverage: the
|
|
// rows must cover the rule and none may widen it. Re-adding must not duplicate,
|
|
// and one remove must clear every transport.
|
|
const p uint16 = 3494
|
|
rule := &Rule{Proto: TCPUDP, Port: p, Action: Accept}
|
|
match := func(r *Rule) bool { return r.Port == p && onProtocolAxis(r.Proto) }
|
|
|
|
if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) {
|
|
t.Skip("backend cannot express a both-transports port rule")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
t.Cleanup(func() {
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if match(r) {
|
|
_ = mgr.RemoveRule(ctx, zone, r)
|
|
}
|
|
}
|
|
})
|
|
|
|
found := func() []*Rule {
|
|
var out []*Rule
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if match(r) {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
got := found()
|
|
require.NotEmpty(t, got, "the rule must read back")
|
|
require.True(t, rule.CoveredBy(got), "the stored rows must cover both transports, got %+v", got)
|
|
for _, r := range got {
|
|
require.True(t, rule.Covers(r), "a stored row must not widen the rule: %+v", r)
|
|
}
|
|
|
|
// Re-adding the same rule is a no-op: every row it fans into dedups against
|
|
// what is already stored.
|
|
require.NoError(t, mgr.AddRule(ctx, zone, rule))
|
|
require.Len(t, found(), len(got), "re-adding the rule must not duplicate its rows")
|
|
|
|
// One remove clears both transports.
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
|
|
require.Empty(t, found(), "removing the rule must clear both transports")
|
|
})
|
|
|
|
t.Run("tcpudpsplitremove", func(t *testing.T) {
|
|
// The protocol analog of familyanysplitremove: removing ONE transport of a
|
|
// both-transports rule must leave the other in place. Backends that store the
|
|
// pair as two rows drop only the targeted row; nftables splits its single
|
|
// `meta l4proto { tcp, udp }` row and re-adds the survivor.
|
|
const p uint16 = 3495
|
|
both := &Rule{Proto: TCPUDP, Port: p, Action: Accept}
|
|
tcp := &Rule{Proto: TCP, Port: p, Action: Accept}
|
|
udp := &Rule{Proto: UDP, Port: p, Action: Accept}
|
|
match := func(r *Rule) bool { return r.Port == p && onProtocolAxis(r.Proto) }
|
|
|
|
coverage := func() (tcpCov, udpCov bool) {
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if !match(r) {
|
|
continue
|
|
}
|
|
switch r.Proto {
|
|
case TCPUDP:
|
|
tcpCov, udpCov = true, true
|
|
case TCP:
|
|
tcpCov = true
|
|
case UDP:
|
|
udpCov = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
clear := func() {
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if match(r) {
|
|
_ = mgr.RemoveRule(ctx, zone, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := mgr.AddRule(ctx, zone, both); errors.Is(err, ErrUnsupported) {
|
|
t.Skip("backend cannot express a both-transports port rule")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
t.Cleanup(clear)
|
|
if hasT, hasU := coverage(); !(hasT && hasU) {
|
|
clear()
|
|
t.Skipf("backend does not give this rule both-transport coverage (tcp=%v udp=%v)", hasT, hasU)
|
|
}
|
|
|
|
// Remove TCP; UDP must survive.
|
|
if err := mgr.RemoveRule(ctx, zone, tcp); errors.Is(err, ErrUnsupported) {
|
|
t.Skip("backend cannot express a single-transport removal of this rule")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
hasT, hasU := coverage()
|
|
require.False(t, hasT, "removing tcp must clear tcp coverage")
|
|
require.True(t, hasU, "removing tcp from a tcpudp rule must leave udp in place")
|
|
|
|
// Opposite direction from a clean slate: remove UDP, TCP must survive.
|
|
clear()
|
|
require.NoError(t, mgr.AddRule(ctx, zone, both))
|
|
if hasT, hasU := coverage(); !(hasT && hasU) {
|
|
t.Fatalf("re-adding the tcpudp rule must restore both transports (tcp=%v udp=%v)", hasT, hasU)
|
|
}
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, udp))
|
|
hasT, hasU = coverage()
|
|
require.False(t, hasU, "removing udp must clear udp coverage")
|
|
require.True(t, hasT, "removing udp from a tcpudp rule must leave tcp in place")
|
|
})
|
|
|
|
t.Run("diranysplitremove", func(t *testing.T) {
|
|
// A DirAny rule applies in BOTH directions. On the chain backends it is stored
|
|
// as an inbound rule plus a role-swapped outbound rule (two physical rows); on
|
|
// csf/apf a bare host is one bidirectional plain csf.allow/allow_hosts line and
|
|
// any ported shape fans out into two hook rules. Either way GetRules collapses
|
|
// it back to one DirAny rule. Removing a single direction must leave the other
|
|
// in place — the chain backends drop only that direction's row, while csf/apf
|
|
// either split their plain line and re-express the survivor through the
|
|
// raw-iptables hook (bare host) or drop just that direction's hook rule
|
|
// (ported). Backends with no output concept (firewalld) reject the DirAny add
|
|
// with ErrUnsupported and skip. This is the direction analog of
|
|
// familyanysplitremove: removing the input half of a DirAny rule must never
|
|
// drop the output half, mirroring a concrete-family removal leaving the other
|
|
// family in place.
|
|
requireCap(t, caps.Output)
|
|
|
|
// splitCase is one dual-direction rule shape, the concrete-direction removal
|
|
// targets (inbound framed as a source match, outbound as the role-swapped
|
|
// destination match), and a matcher reporting which directions a read-back rule
|
|
// covers for the shape.
|
|
type splitCase struct {
|
|
anyRule, inTarget, outTarget *Rule
|
|
cover func(*Rule) (inCov, outCov bool)
|
|
}
|
|
|
|
// runSplit exercises a shape: add the DirAny rule, remove each direction in
|
|
// turn, and confirm the other survives (or the backend rejects the removal).
|
|
runSplit := func(t *testing.T, c splitCase) {
|
|
coverage := func() (inCov, outCov bool) {
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
in, out := c.cover(r)
|
|
inCov = inCov || in
|
|
outCov = outCov || out
|
|
}
|
|
return
|
|
}
|
|
clear := func() {
|
|
_ = mgr.RemoveRule(ctx, zone, c.anyRule)
|
|
_ = mgr.RemoveRule(ctx, zone, c.inTarget)
|
|
_ = mgr.RemoveRule(ctx, zone, c.outTarget)
|
|
}
|
|
|
|
// The backend must express this bidirectional shape; skip where it cannot.
|
|
if err := mgr.AddRule(ctx, zone, c.anyRule); errors.Is(err, ErrUnsupported) {
|
|
t.Skip("backend cannot express this DirAny shape")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
t.Cleanup(clear)
|
|
if inCov, outCov := coverage(); !(inCov && outCov) {
|
|
clear()
|
|
t.Skipf("backend does not give this DirAny shape dual-direction coverage (in=%v out=%v)", inCov, outCov)
|
|
}
|
|
|
|
// Remove the input direction; output must survive.
|
|
if err := mgr.RemoveRule(ctx, zone, c.inTarget); errors.Is(err, ErrUnsupported) {
|
|
t.Skip("backend cannot express a single-direction removal of this DirAny shape")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
inCov, outCov := coverage()
|
|
require.False(t, inCov, "removing the input direction must clear input coverage")
|
|
require.True(t, outCov, "removing the input direction from a DirAny rule must leave output in place")
|
|
|
|
// Opposite direction from a clean slate: remove output, input must survive.
|
|
clear()
|
|
require.NoError(t, mgr.AddRule(ctx, zone, c.anyRule))
|
|
if inCov, outCov := coverage(); !(inCov && outCov) {
|
|
t.Fatalf("re-adding the DirAny rule must restore both directions (in=%v out=%v)", inCov, outCov)
|
|
}
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, c.outTarget))
|
|
inCov, outCov = coverage()
|
|
require.False(t, outCov, "removing the output direction must clear output coverage")
|
|
require.True(t, inCov, "removing the output direction from a DirAny rule must leave input in place")
|
|
}
|
|
|
|
t.Run("barehost", func(t *testing.T) {
|
|
// A bare host allow: on csf/apf this is the single-plain-line shape whose
|
|
// single-direction removal splits the line and re-adds the survivor via the
|
|
// hook (splitDualRowDirection).
|
|
const host = "192.0.2.77"
|
|
runSplit(t, splitCase{
|
|
anyRule: &Rule{Direction: DirAny, Source: host, Action: Accept},
|
|
inTarget: &Rule{Direction: DirInput, Source: host, Action: Accept},
|
|
outTarget: &Rule{Direction: DirOutput, Destination: host, Action: Accept},
|
|
cover: func(r *Rule) (inCov, outCov bool) {
|
|
switch r.Direction {
|
|
case DirAny:
|
|
if addrEqual(r.Source, host) {
|
|
return true, true
|
|
}
|
|
case DirInput:
|
|
if addrEqual(r.Source, host) {
|
|
return true, false
|
|
}
|
|
case DirOutput:
|
|
if addrEqual(r.Destination, host) {
|
|
return false, true
|
|
}
|
|
}
|
|
return false, false
|
|
},
|
|
})
|
|
})
|
|
|
|
t.Run("portedhost", func(t *testing.T) {
|
|
// A ported DirAny rule is NOT a bare-host plain line: it fans out into an
|
|
// inbound dport row and its role-swapped outbound sport twin — two physical
|
|
// rows on the chain backends, two hook rules on csf/apf. The two rows share
|
|
// an identical inbound-frame match, so only the direction guard in
|
|
// EqualForRemoval keeps a single-direction removal from taking the twin as
|
|
// well. This is the direction analog of the family split's destport/
|
|
// sourceport cases.
|
|
const host = "192.0.2.81"
|
|
const p uint16 = 3499
|
|
runSplit(t, splitCase{
|
|
anyRule: &Rule{Direction: DirAny, Proto: TCP, Port: p, Source: host, Action: Accept},
|
|
inTarget: &Rule{Direction: DirInput, Proto: TCP, Port: p, Source: host, Action: Accept},
|
|
outTarget: &Rule{Direction: DirOutput, Proto: TCP, SourcePort: p, Destination: host, Action: Accept},
|
|
cover: func(r *Rule) (inCov, outCov bool) {
|
|
if r.Proto != TCP {
|
|
return false, false
|
|
}
|
|
soleDest := func() bool {
|
|
s := r.PortSpecs()
|
|
return len(s) == 1 && s[0].Start == p && !r.HasSourcePorts()
|
|
}
|
|
soleSource := func() bool {
|
|
s := r.SourcePortSpecs()
|
|
return len(s) == 1 && s[0].Start == p && !r.HasPorts()
|
|
}
|
|
switch r.Direction {
|
|
case DirAny:
|
|
// A bidirectional row, stated in the inbound frame: dport p from
|
|
// the host.
|
|
if addrEqual(r.Source, host) && soleDest() {
|
|
return true, true
|
|
}
|
|
case DirInput:
|
|
if addrEqual(r.Source, host) && soleDest() {
|
|
return true, false
|
|
}
|
|
case DirOutput:
|
|
// The surviving outbound twin: sport p to the host.
|
|
if addrEqual(r.Destination, host) && soleSource() {
|
|
return false, true
|
|
}
|
|
}
|
|
return false, false
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
t.Run("diranyroundtrip", func(t *testing.T) {
|
|
// A DirAny rule reads back as whatever the backend stores: one bidirectional
|
|
// line where its config has that form (csf.allow, apf's allow_hosts), otherwise
|
|
// an inbound row plus its role-swapped outbound row. Either way the rows must
|
|
// cover the rule and none may widen it, and a second add must be an idempotent
|
|
// no-op rather than doubling the rows.
|
|
requireCap(t, caps.Output)
|
|
rule := &Rule{Direction: DirAny, Source: "192.0.2.78", Action: Accept}
|
|
if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) {
|
|
t.Skip("backend cannot express a bidirectional bare host allow")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
|
|
|
|
matches := func() []*Rule {
|
|
var out []*Rule
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if addrEqual(r.Source, "192.0.2.78") || addrEqual(r.Destination, "192.0.2.78") {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
got := matches()
|
|
require.True(t, rule.CoveredBy(got), "the stored rows must cover both directions, got %+v", got)
|
|
for _, r := range got {
|
|
require.True(t, rule.Covers(r), "a stored row must not widen the rule: %+v", r)
|
|
}
|
|
|
|
// A redundant add must not create a second copy of any row.
|
|
require.NoError(t, mgr.AddRule(ctx, zone, rule))
|
|
require.Len(t, matches(), len(got), "re-adding an existing DirAny rule must be a no-op")
|
|
})
|
|
|
|
t.Run("diranyported", func(t *testing.T) {
|
|
// A DirAny rule that is NOT a bare host — here a host + destination port — must
|
|
// still round-trip: it fans out into an inbound (dport) rule and its role-
|
|
// swapped outbound (sport) twin, which together cover the rule on read. This
|
|
// exercises the fan-out path that a plain csf.allow/apf line (bare host) does
|
|
// not use. Skip where the backend cannot express the shape.
|
|
requireCap(t, caps.Output)
|
|
rule := &Rule{Direction: DirAny, Proto: TCP, Port: 22, Source: "192.0.2.80", Action: Accept}
|
|
if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) {
|
|
t.Skip("backend cannot express this DirAny ported host rule")
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
|
|
require.True(t, rule.CoveredBy(rulesOf(t, ctx, mgr, zone)),
|
|
"the stored rows must cover both directions of the ported host rule")
|
|
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
|
|
require.False(t, rule.CoveredBy(rulesOf(t, ctx, mgr, zone)),
|
|
"DirAny ported host rule still present after removal")
|
|
})
|
|
|
|
t.Run("diranynooutputfallback", func(t *testing.T) {
|
|
// On a backend with no output concept (firewalld), a DirAny rule cannot be a
|
|
// both-directions rule, so it must degrade to its input half rather than error:
|
|
// the add succeeds and reads back as an input rule, and the same DirAny target
|
|
// removes it.
|
|
if caps.Output {
|
|
t.Skip("backend distinguishes output; DirAny fans out instead of degrading")
|
|
}
|
|
const host = "192.0.2.79"
|
|
rule := &Rule{Direction: DirAny, Source: host, Action: Accept}
|
|
if err := mgr.AddRule(ctx, zone, rule); errors.Is(err, ErrUnsupported) {
|
|
t.Skip("backend cannot express this host allow at all")
|
|
} else {
|
|
require.NoError(t, err, "a DirAny rule must degrade to input, not error, on a no-output backend")
|
|
}
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
|
|
|
|
found := false
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
if r.Direction == DirInput && addrEqual(r.Source, host) {
|
|
found = true
|
|
}
|
|
}
|
|
require.True(t, found, "the degraded DirAny rule must read back as an input rule")
|
|
|
|
// Removal by the same DirAny target must clear it.
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
require.Falsef(t, addrEqual(r.Source, host), "degraded DirAny rule still present: %+v", r)
|
|
}
|
|
})
|
|
|
|
t.Run("icmp", func(t *testing.T) {
|
|
// ICMP has genuinely different shapes per backend: nft/iptables/ufw accept a
|
|
// bare rule; apf models ICMP as a list of allowed types, so a rule with an
|
|
// address or a non-accept action goes to its hook; csf builds native ICMP on
|
|
// host-based advanced rules needing an address AND a type (the advanced-rule
|
|
// format carries the type in the single port-flow field), so every other shape
|
|
// goes to its hook. Both hook forms are plain iptables rules, so the bare
|
|
// variant is what csf and apf match here. Offer all forms and use the first the
|
|
// backend accepts.
|
|
roundTripVariants(t, ctx, mgr, zone,
|
|
&Rule{Family: IPv4, Proto: ICMP, Action: Accept},
|
|
&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
|
&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "192.0.2.0/24", Action: Accept},
|
|
)
|
|
})
|
|
|
|
t.Run("icmpv6", func(t *testing.T) {
|
|
requireCap(t, caps.ICMPv6)
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv6, Proto: ICMPv6, Action: Accept})
|
|
})
|
|
|
|
t.Run("reload", func(t *testing.T) {
|
|
// Every backend must survive an actual reload/apply with a managed rule in
|
|
// place: csf must ride out its restart lock, apf must be able to run
|
|
// `apf --restart`. The v6 variant below covers the backends that keep IPv6
|
|
// rules in a separate file, but it is gated on ICMPv6 support — which csf and
|
|
// apf drop when their own IPv6 handling is off — so exercise Reload here for
|
|
// everyone with a plain IPv4-expressible rule.
|
|
r := &Rule{Proto: TCP, Port: 3530, Action: Accept}
|
|
require.NoError(t, mgr.AddRule(ctx, zone, r))
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
|
|
require.NoError(t, mgr.Reload(ctx), "reload must succeed with a managed rule present")
|
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), "rule missing after reload")
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
|
|
require.NoError(t, mgr.Reload(ctx), "reload must succeed after removing the rule")
|
|
})
|
|
|
|
t.Run("reloadv6raw", func(t *testing.T) {
|
|
requireCap(t, caps.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 addressed fallback for a backend whose native
|
|
// type list cannot carry the bare form. Use type 13 (Timestamp) rather than the
|
|
// more common echo-request type 8 to avoid colliding with Windows' built-in
|
|
// echo-request rules, which cannot be removed and would make the round-trip
|
|
// assertions match the wrong rule.
|
|
roundTripVariants(t, ctx, mgr, zone,
|
|
&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Accept},
|
|
&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.0/24", Action: Accept},
|
|
)
|
|
})
|
|
|
|
t.Run("icmptypedhost", func(t *testing.T) {
|
|
// A typed ICMP match carrying a host address. The shared icmp/icmptype
|
|
// probes stop at the first form a backend accepts, which hides this one:
|
|
// it is csf's only NATIVE ICMP form (a csf.allow advanced line carries
|
|
// exactly one address and a concrete type; every other shape routes to its
|
|
// hook, see CSF.needsHook), and on apf — whose native ICMP is the address-less
|
|
// IG_ICMP_TYPES list — the shape that routes to the hook (needsHook). Every
|
|
// backend runs it so both paths, and everyone else's addressed ICMP match,
|
|
// stay covered. Type 13 (Timestamp) avoids colliding with Windows'
|
|
// unremovable built-in echo-request rules.
|
|
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Source: "192.0.2.70/32", Action: Accept})
|
|
})
|
|
|
|
t.Run("icmptypeddrop", func(t *testing.T) {
|
|
// A typed ICMP drop with no address. A non-accept action has no place in
|
|
// apf's allowed-type list, so it routes to apf's hook; the other backends
|
|
// express it directly or skip via the ErrUnsupported sentinel.
|
|
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](13), Action: Drop})
|
|
})
|
|
|
|
t.Run("singlefamilyport", func(t *testing.T) {
|
|
requireCap(t, caps.FamilyWithoutAddress)
|
|
// A single-family bare port accept. Most backends store it directly; apf's
|
|
// CPORTS port lists are dual-stack, so it has no native form there and is
|
|
// written per-family through the hook (dualStackPortNeedsHook) rather than
|
|
// rejected. wf advertises FamilyWithoutAddress false (a WFP filter scopes
|
|
// family through its address conditions) and is gated out. The
|
|
// FamilyAny→single-family split is exercised by the destport split test.
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Family: IPv4, Proto: TCP, Port: 8090, Action: Accept})
|
|
})
|
|
|
|
t.Run("portrange", func(t *testing.T) {
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept})
|
|
})
|
|
|
|
t.Run("portlist", func(t *testing.T) {
|
|
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). csf/apf have no address-less advanced-rule form, so they
|
|
// route this through the raw-iptables hook; the addressed fallback remains for
|
|
// any backend that needs an address on a source-port rule.
|
|
roundTripVariants(t, ctx, mgr, zone,
|
|
&Rule{Proto: TCP, SourcePort: 1234, Action: Accept},
|
|
&Rule{Proto: TCP, SourcePort: 1234, Source: "192.0.2.0/24", Action: Accept},
|
|
)
|
|
})
|
|
|
|
t.Run("connstate", func(t *testing.T) {
|
|
requireCap(t, caps.ConnState)
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, State: StateEstablished | StateRelated, Action: Accept})
|
|
})
|
|
|
|
t.Run("interface", func(t *testing.T) {
|
|
requireCap(t, caps.InterfaceMatch)
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, InInterface: "eth0", Action: Accept})
|
|
})
|
|
|
|
t.Run("logging", func(t *testing.T) {
|
|
requireCap(t, caps.Logging)
|
|
// pf logs but has no text prefix on the `log` keyword, so fall back to a
|
|
// prefix-less logged rule there.
|
|
roundTripVariants(t, ctx, mgr, zone,
|
|
&Rule{Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "it"},
|
|
&Rule{Proto: TCP, Port: 22, Action: Accept, Log: true},
|
|
)
|
|
})
|
|
|
|
t.Run("ratelimit", func(t *testing.T) {
|
|
requireCap(t, caps.RateLimit)
|
|
roundTripRule(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}})
|
|
})
|
|
|
|
t.Run("connlimit", func(t *testing.T) {
|
|
requireCap(t, caps.ConnLimit)
|
|
// Connection limiting splits across backends: nft expresses only a global
|
|
// cap; pf a per-source one only on an accept rule (single inbound tcp port, no
|
|
// address); iptables does the global form. csf and apf carry one native shape
|
|
// each in their config and route every other shape to their hook, whose
|
|
// iptables rule does the global form too. Try each and round-trip the first the
|
|
// backend accepts.
|
|
roundTripVariants(t, ctx, mgr, zone,
|
|
&Rule{Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}},
|
|
&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: true}},
|
|
&Rule{Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20, PerSource: true}},
|
|
&Rule{Proto: TCP, Port: 80, Action: Accept, ConnLimit: &ConnLimit{Count: 20, PerSource: true}},
|
|
)
|
|
})
|
|
|
|
t.Run("connlimitpersource", func(t *testing.T) {
|
|
requireCap(t, caps.ConnLimit)
|
|
// A per-source connection cap on a single address-less inbound tcp port,
|
|
// rejecting the excess. The shared connlimit probe stops at the first form
|
|
// a backend accepts, which hides this one wherever a global form matched
|
|
// first: on csf and apf it is the single NATIVE connection-limit shape
|
|
// (csf.conf CONNLIMIT / conf.apf CLIMIT) while every other shape routes to
|
|
// their hook, and nft counts it in a named meter set. Every
|
|
// connlimit-capable backend runs it; pf alone skips via the ErrUnsupported
|
|
// sentinel (its max-src-conn applies only to a pass rule, so a rejecting
|
|
// per-source cap has no pf form).
|
|
roundTripRuleOrSkip(t, ctx, mgr, zone, &Rule{Proto: TCP, Port: 8081, Action: Reject, ConnLimit: &ConnLimit{Count: 15, PerSource: true}})
|
|
})
|
|
|
|
t.Run("denyremovedanyaction", func(t *testing.T) {
|
|
requireCap(t, caps.DenyActionFromConfig)
|
|
// On a backend whose native deny store carries no per-entry action (the
|
|
// tool applies its config's action — see DenyActionFromConfig), the deny of
|
|
// an address is a single entry (csf.deny, apf deny_hosts.rules) and must be
|
|
// removable whatever action the caller names. Otherwise RemoveRule reports
|
|
// success while the tool keeps enforcing the entry. The stock config action
|
|
// is DROP on both, so Drop is the native deny action and Reject the
|
|
// differing one.
|
|
host := "192.0.2.72/32"
|
|
added := &Rule{Family: IPv4, Proto: TCP, Port: 8084, Source: host, Action: Drop}
|
|
require.NoError(t, mgr.AddRule(ctx, zone, added))
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, added) })
|
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), added, mgr.Capabilities().Output),
|
|
"the native deny must be present before removal")
|
|
|
|
// The same rule, named with the other deny action, must still clear the entry.
|
|
differing := *added
|
|
differing.Action = Reject
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, &differing))
|
|
for _, r := range rulesOf(t, ctx, mgr, zone) {
|
|
require.Falsef(t, addrEqual(r.Source, host) && r.Port == 8084,
|
|
"the deny entry must be removed whatever action the target names: %+v", r)
|
|
}
|
|
})
|
|
|
|
t.Run("comment", func(t *testing.T) {
|
|
requireCap(t, caps.Comments)
|
|
// Most backends carry a comment on a bare port rule; CSF and APF can only
|
|
// attach one to an address-based (IP-list) rule, so fall back to that
|
|
// form when the first probe succeeds but drops the comment.
|
|
variants := []*Rule{
|
|
{Proto: TCP, Port: 22, Action: Accept, Comment: "it-comment"},
|
|
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.0/24", Action: Accept, Comment: "it-comment"},
|
|
}
|
|
for _, rule := range variants {
|
|
rule := rule
|
|
require.NoError(t, mgr.AddRule(ctx, zone, rule))
|
|
got := findRule(t, ctx, mgr, zone, rule)
|
|
if got.Comment == rule.Comment {
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output), "rule still present after removal")
|
|
return
|
|
}
|
|
// Comment did not round-trip on this form; remove and try the next.
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
|
|
}
|
|
t.Fatal("comment did not round-trip on any probe form")
|
|
})
|
|
|
|
t.Run("priority", func(t *testing.T) {
|
|
requireCap(t, caps.Priority)
|
|
// A rule reads back carrying its priority, and priority is part of rule
|
|
// identity: an otherwise-identical rule at a different priority is a
|
|
// distinct rule, so a reconcile can actually change a rule's priority.
|
|
r := &Rule{Family: IPv4, Proto: TCP, Port: 5100, Action: Accept, Priority: 10}
|
|
require.NoError(t, mgr.AddRule(ctx, zone, r))
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
|
|
|
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output),
|
|
"a rule must read back carrying its priority")
|
|
other := &Rule{Family: IPv4, Proto: TCP, Port: 5100, Action: Accept, Priority: 20}
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), other, mgr.Capabilities().Output),
|
|
"a rule differing only in priority must be a distinct rule")
|
|
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), r, mgr.Capabilities().Output), "rule still present after removal")
|
|
})
|
|
|
|
// --- NAT ------------------------------------------------------------------
|
|
|
|
t.Run("nat", func(t *testing.T) {
|
|
requireCap(t, caps.NAT)
|
|
// Each entry is a kind with one or more forms; the first the backend accepts
|
|
// (not rejected with ErrUnsupportedNAT) is round-tripped. A kind no backend
|
|
// form accepts is skipped — e.g. pf has no standalone Redirect, and masquerade
|
|
// takes an interface on pf/nft/iptables but firewalld forbids one.
|
|
natVariants := [][]*NATRule{
|
|
{{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}},
|
|
{{Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080}},
|
|
{{Kind: SNAT, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"}},
|
|
{
|
|
{Kind: Masquerade, Interface: "eth1"},
|
|
{Kind: Masquerade},
|
|
},
|
|
}
|
|
for _, variants := range natVariants {
|
|
variants := variants
|
|
t.Run(variants[0].Kind.String(), func(t *testing.T) {
|
|
roundTripNATVariants(t, ctx, mgr, zone, variants)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("natfamilypairremove", func(t *testing.T) {
|
|
requireCap(t, caps.NAT)
|
|
// A v4 masquerade and its v6 twin on the same interface may be one row (nft's
|
|
// inet table, a pf rule with no af) or two. Removing every rule the backend
|
|
// reports must clear them all. Regression for a NAT remove that stopped at the
|
|
// first match and left the IPv6 twin loaded (pf), mirroring the filter-side
|
|
// familypairremove probe.
|
|
v4 := &NATRule{Kind: Masquerade, Family: IPv4, Interface: "eth1"}
|
|
v6 := &NATRule{Kind: Masquerade, Family: IPv6, Interface: "eth1"}
|
|
added := 0
|
|
for _, r := range []*NATRule{v4, v6} {
|
|
err := mgr.AddNATRule(ctx, zone, r)
|
|
if errors.Is(err, ErrUnsupportedNAT) {
|
|
continue // the backend cannot express this family of an interface masquerade.
|
|
}
|
|
require.NoError(t, err)
|
|
added++
|
|
}
|
|
if added < 2 {
|
|
t.Skip("backend does not express both families of an interface masquerade")
|
|
}
|
|
t.Cleanup(func() {
|
|
_ = mgr.RemoveNATRule(ctx, zone, v4)
|
|
_ = mgr.RemoveNATRule(ctx, zone, v6)
|
|
})
|
|
|
|
// Remove every masquerade the backend reports for this interface (one
|
|
// family-agnostic rule, or one per family), then confirm none remain.
|
|
isMasq := func(r *NATRule) bool { return r.Kind == Masquerade && r.Interface == "eth1" }
|
|
nats, err := mgr.GetNATRules(ctx, zone)
|
|
require.NoError(t, err)
|
|
for _, r := range nats {
|
|
if isMasq(r) {
|
|
require.NoError(t, mgr.RemoveNATRule(ctx, zone, r))
|
|
}
|
|
}
|
|
nats, err = mgr.GetNATRules(ctx, zone)
|
|
require.NoError(t, err)
|
|
for _, r := range nats {
|
|
require.False(t, isMasq(r), "masquerade still present after removal: %+v", r)
|
|
}
|
|
})
|
|
|
|
// --- rule ordering --------------------------------------------------------
|
|
|
|
t.Run("ordering", func(t *testing.T) {
|
|
requireCap(t, caps.RuleOrdering)
|
|
r1 := &Rule{Family: IPv4, Proto: TCP, Port: 3001, Action: Accept}
|
|
r2 := &Rule{Family: IPv4, Proto: TCP, Port: 3002, Action: Accept}
|
|
r3 := &Rule{Family: IPv4, Proto: TCP, Port: 3003, Action: Accept}
|
|
byPort := map[uint16]*Rule{3001: r1, 3002: r2, 3003: r3}
|
|
for _, r := range []*Rule{r1, r2, r3} {
|
|
require.NoError(t, mgr.AddRule(ctx, zone, r))
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
|
|
}
|
|
|
|
// Whether AddRule appends or prepends is backend-specific (nft appends,
|
|
// iptables inserts at the top), so read the actual order rather than assume
|
|
// it, then verify MoveRule relocates a rule relative to that order.
|
|
order0 := managedPorts(t, ctx, mgr, zone, []uint16{3001, 3002, 3003})
|
|
require.Len(t, order0, 3)
|
|
|
|
// Move the currently-first rule to the end: it should land last while the
|
|
// other two keep their relative order.
|
|
require.NoError(t, mgr.MoveRule(ctx, zone, byPort[order0[0]], 3))
|
|
want := []uint16{order0[1], order0[2], order0[0]}
|
|
got := managedPorts(t, ctx, mgr, zone, []uint16{3001, 3002, 3003})
|
|
require.Equal(t, want, got, "MoveRule to the end should relocate the first rule")
|
|
|
|
// Insert a new rule at the front.
|
|
r0 := &Rule{Family: IPv4, Proto: TCP, Port: 3000, Action: Accept}
|
|
require.NoError(t, mgr.InsertRule(ctx, zone, 1, r0))
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r0) })
|
|
ports := managedPorts(t, ctx, mgr, zone, []uint16{3000, 3001, 3002, 3003})
|
|
require.Equal(t, uint16(3000), ports[0], "InsertRule at position 1 should place the rule first")
|
|
|
|
// GetRules populates a 1-based Number that increases in chain order, so a
|
|
// caller can read a rule's position from the returned set.
|
|
requireAscendingNumbers(t, managedNumbers(t, ctx, mgr, zone, []uint16{3000, 3001, 3002, 3003}))
|
|
})
|
|
|
|
t.Run("familyanymove", func(t *testing.T) {
|
|
requireCap(t, caps.RuleOrdering)
|
|
// A v4 rule and its v6 twin may occupy two physical rows. Moving them with one
|
|
// FamilyAny target must relocate BOTH as a unit: a naive move drags only one
|
|
// row and orphans the twin at its old position.
|
|
const port = 3510
|
|
v4 := &Rule{Family: IPv4, Proto: TCP, Port: port, Action: Accept}
|
|
v6 := &Rule{Family: IPv6, Proto: TCP, Port: port, Action: Accept}
|
|
added := 0
|
|
for _, r := range []*Rule{v4, v6} {
|
|
err := mgr.AddRule(ctx, zone, r)
|
|
if errors.Is(err, ErrUnsupported) {
|
|
continue
|
|
}
|
|
require.NoError(t, err)
|
|
added++
|
|
}
|
|
if added < 2 {
|
|
t.Skip("backend does not express both families of a bare-port accept (no twin to move)")
|
|
}
|
|
b := &Rule{Family: IPv4, Proto: TCP, Port: 3511, Action: Accept}
|
|
require.NoError(t, mgr.AddRule(ctx, zone, b))
|
|
t.Cleanup(func() {
|
|
_ = mgr.RemoveRule(ctx, zone, v4)
|
|
_ = mgr.RemoveRule(ctx, zone, v6)
|
|
_ = mgr.RemoveRule(ctx, zone, b)
|
|
})
|
|
|
|
before := managedPorts(t, ctx, mgr, zone, []uint16{port})
|
|
require.NotEmpty(t, before)
|
|
|
|
// Move every row of the pair to the end with one FamilyAny target. The chain
|
|
// holds b plus the pair's rows, so a position past the last row appends. A
|
|
// backend that can store one family-agnostic row (nft's inet table) re-adds the
|
|
// target as that single row rather than the two it replaced, so the row count
|
|
// may shrink — what must hold is that b now leads, no row of the pair is left
|
|
// behind it, and both families still have coverage.
|
|
twin := &Rule{Family: FamilyAny, Proto: TCP, Port: port, Action: Accept}
|
|
require.NoError(t, mgr.MoveRule(ctx, zone, twin, len(before)+2))
|
|
|
|
order := managedPorts(t, ctx, mgr, zone, []uint16{port, 3511})
|
|
require.NotEmpty(t, order)
|
|
require.Equal(t, uint16(3511), order[0],
|
|
"b must now be first: every row of the pair moved past it")
|
|
for _, p := range order[1:] {
|
|
require.EqualValues(t, port, p, "no row of the pair may be left before b")
|
|
}
|
|
require.True(t, twin.CoveredBy(rulesOf(t, ctx, mgr, zone)),
|
|
"both families must survive the move")
|
|
})
|
|
|
|
t.Run("insertposition", func(t *testing.T) {
|
|
requireCap(t, caps.RuleOrdering)
|
|
// GetRules reports one rule per stored row, each with the Number of its own
|
|
// position. Inserting before a rule's reported Number must land exactly there,
|
|
// whatever rows precede it.
|
|
a4 := &Rule{Family: IPv4, Proto: TCP, Port: 3520, Action: Accept}
|
|
a6 := &Rule{Family: IPv6, Proto: TCP, Port: 3520, Action: Accept}
|
|
b4 := &Rule{Family: IPv4, Proto: TCP, Port: 3521, Action: Accept}
|
|
b6 := &Rule{Family: IPv6, Proto: TCP, Port: 3521, Action: Accept}
|
|
added := 0
|
|
for _, r := range []*Rule{a4, a6, b4, b6} {
|
|
err := mgr.AddRule(ctx, zone, r)
|
|
if errors.Is(err, ErrUnsupported) {
|
|
continue
|
|
}
|
|
require.NoError(t, err)
|
|
added++
|
|
}
|
|
if added < 4 {
|
|
t.Skip("backend does not express both families of both bare-port pairs")
|
|
}
|
|
c := &Rule{Family: IPv4, Proto: TCP, Port: 3522, Action: Accept}
|
|
require.NoError(t, mgr.AddRule(ctx, zone, c))
|
|
t.Cleanup(func() {
|
|
for _, r := range []*Rule{a4, a6, b4, b6, c} {
|
|
_ = mgr.RemoveRule(ctx, zone, r)
|
|
}
|
|
})
|
|
|
|
// Read c's Number (its position, whatever the backend's add order) and insert
|
|
// d there. c is IPv4-only, so it reads back as exactly one rule.
|
|
nums := managedNumbers(t, ctx, mgr, zone, []uint16{3522})
|
|
require.Len(t, nums, 1, "c is IPv4-only and reads back as one rule")
|
|
cNum := nums[0]
|
|
|
|
d := &Rule{Family: IPv4, Proto: TCP, Port: 3523, Action: Accept}
|
|
require.NoError(t, mgr.InsertRule(ctx, zone, cNum, d))
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, d) })
|
|
|
|
// d must land immediately before c.
|
|
order := managedPorts(t, ctx, mgr, zone, []uint16{3520, 3521, 3522, 3523})
|
|
var ci int
|
|
for i, p := range order {
|
|
if p == 3522 {
|
|
ci = i
|
|
}
|
|
}
|
|
require.Greater(t, ci, 0, "d must be inserted before c, so c is not first")
|
|
require.Equal(t, uint16(3523), order[ci-1], "d must land immediately before c")
|
|
})
|
|
|
|
// --- NAT ordering ---------------------------------------------------------
|
|
|
|
t.Run("natordering", func(t *testing.T) {
|
|
requireCap(t, caps.NAT)
|
|
requireCap(t, caps.RuleOrdering)
|
|
|
|
// DNAT rules share the prerouting chain and differ only by matched port, so
|
|
// their read-back order reflects the requested positions.
|
|
n1 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 4001, ToAddress: "10.9.0.1", ToPort: 5001}
|
|
n2 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 4002, ToAddress: "10.9.0.2", ToPort: 5002}
|
|
for _, n := range []*NATRule{n1, n2} {
|
|
require.NoError(t, mgr.AddNATRule(ctx, zone, n))
|
|
t.Cleanup(func() { _ = mgr.RemoveNATRule(ctx, zone, n) })
|
|
}
|
|
|
|
// Insert a third DNAT rule at the front of the chain.
|
|
n0 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 4000, ToAddress: "10.9.0.0", ToPort: 5000}
|
|
require.NoError(t, mgr.InsertNATRule(ctx, zone, 1, n0))
|
|
t.Cleanup(func() { _ = mgr.RemoveNATRule(ctx, zone, n0) })
|
|
|
|
got := managedNATPorts(t, ctx, mgr, zone, []uint16{4000, 4001, 4002})
|
|
require.Equal(t, uint16(4000), got[0], "InsertNATRule at position 1 should place the rule first")
|
|
|
|
// GetNATRules populates a 1-based Number that increases in chain order.
|
|
requireAscendingNumbers(t, managedNATNumbers(t, ctx, mgr, zone, []uint16{4000, 4001, 4002}))
|
|
})
|
|
|
|
// --- 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")
|
|
|
|
// A FamilyAny set reference is accepted and pinned to the set's own family
|
|
// rather than rejected: the set is family-typed, so the rule could never
|
|
// match the other family anyway. containsRule compares family-agnostically,
|
|
// so the pinned read-back still satisfies the FamilyAny target, and the
|
|
// same target removes it.
|
|
anyRule := &Rule{Proto: TCP, Port: 4202, Source: set.Name, Action: Accept}
|
|
require.NoError(t, mgr.AddRule(ctx, zone, anyRule),
|
|
"a FamilyAny set reference must be pinned to the set's family, not rejected")
|
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), anyRule, mgr.Capabilities().Output),
|
|
"FamilyAny rule matching on set %q not found", set.Name)
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, anyRule))
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), anyRule, mgr.Capabilities().Output),
|
|
"FamilyAny set-matching rule still present after removal")
|
|
|
|
require.NoError(t, mgr.RemoveAddressSetEntry(ctx, set.Name, "192.0.2.10"))
|
|
sets, err = mgr.GetAddressSets(ctx)
|
|
require.NoError(t, err)
|
|
if got = findSet(sets, set.Name); got != nil {
|
|
require.NotContains(t, got.Entries, "192.0.2.10", "entry should be gone after removal")
|
|
}
|
|
|
|
require.NoError(t, mgr.RemoveAddressSet(ctx, set.Name))
|
|
sets, err = mgr.GetAddressSets(ctx)
|
|
require.NoError(t, err)
|
|
require.Nil(t, findSet(sets, set.Name), "set should be gone after removal")
|
|
|
|
// 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))
|
|
})
|
|
|
|
// --- sync -------------------------------------------------------------------
|
|
|
|
t.Run("sync", func(t *testing.T) {
|
|
// Sync reconciles the zone toward a desired set. Desired is built as the
|
|
// CURRENT state plus two new rules, so the test never strips rules the
|
|
// environment depends on (a run may arrive over SSH), and the three
|
|
// contract points run end-to-end: missing rules are added, a second Sync
|
|
// is a no-op whatever rows the backend fanned the rules into (the diff is
|
|
// the coverage relation, not row equality), and a rule outside desired —
|
|
// freshly added here, but a foreign rule is reconciled the same way — is
|
|
// removed.
|
|
r1 := &Rule{Proto: TCP, Port: 5601, Action: Accept}
|
|
r2 := &Rule{Proto: TCP, Port: 5602, Action: Accept}
|
|
t.Cleanup(func() {
|
|
_ = mgr.RemoveRule(ctx, zone, r1)
|
|
_ = mgr.RemoveRule(ctx, zone, r2)
|
|
})
|
|
desired := append(rulesOf(t, ctx, mgr, zone), r1, r2)
|
|
|
|
added, removed, err := Sync(ctx, mgr, zone, desired)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 2, added, "Sync must add exactly the two missing rules")
|
|
require.Zero(t, removed, "Sync must keep every rule desired covers")
|
|
rules := rulesOf(t, ctx, mgr, zone)
|
|
require.True(t, containsRule(rules, r1, caps.Output), "r1 missing after Sync")
|
|
require.True(t, containsRule(rules, r2, caps.Output), "r2 missing after Sync")
|
|
|
|
// Sync against its own output is a no-op, whichever rows the backend chose
|
|
// to store the rules as.
|
|
added, removed, err = Sync(ctx, mgr, zone, desired)
|
|
require.NoError(t, err)
|
|
require.Zero(t, added, "a second Sync must add nothing, or every run churns")
|
|
require.Zero(t, removed, "a second Sync must remove nothing, or every run churns")
|
|
|
|
// A rule desired does not cover is removed.
|
|
r3 := &Rule{Proto: TCP, Port: 5603, Action: Accept}
|
|
require.NoError(t, mgr.AddRule(ctx, zone, r3))
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r3) })
|
|
added, removed, err = Sync(ctx, mgr, zone, desired)
|
|
require.NoError(t, err)
|
|
require.Zero(t, added, "reconciling away an undesired rule must not re-add anything")
|
|
require.NotZero(t, removed, "Sync must remove the rule desired does not cover")
|
|
rules = rulesOf(t, ctx, mgr, zone)
|
|
require.False(t, containsRule(rules, r3, caps.Output), "undesired rule still present after Sync")
|
|
require.True(t, containsRule(rules, r1, caps.Output), "r1 must survive the reconcile")
|
|
require.True(t, containsRule(rules, r2, caps.Output), "r2 must survive the reconcile")
|
|
})
|
|
|
|
t.Run("replacerules", func(t *testing.T) {
|
|
// ReplaceRulesBatch applies a full desired set in one shot — nft loads one
|
|
// script, iptables one restore file, pf its anchor — so the managed rule
|
|
// space becomes exactly the given set. Only the batching backends carry
|
|
// the method; everywhere else ReplaceRules falls back to Sync, which the
|
|
// sync subtest covers non-destructively (running this probe's small
|
|
// desired set through Sync would strip rules the environment depends on).
|
|
b, ok := mgr.(RuleBatcher)
|
|
if !ok {
|
|
t.Skip("backend has no batch replace; ReplaceRules falls back to Sync (covered by the sync subtest)")
|
|
}
|
|
r1 := &Rule{Proto: TCP, Port: 5604, Action: Accept}
|
|
r2 := &Rule{Proto: TCP, Port: 5605, Action: Accept}
|
|
t.Cleanup(func() {
|
|
_ = mgr.RemoveRule(ctx, zone, r1)
|
|
_ = mgr.RemoveRule(ctx, zone, r2)
|
|
})
|
|
require.NoError(t, b.ReplaceRulesBatch(ctx, zone, []*Rule{r1, r2}))
|
|
rules := rulesOf(t, ctx, mgr, zone)
|
|
require.True(t, containsRule(rules, r1, caps.Output), "r1 missing after batch replace")
|
|
require.True(t, containsRule(rules, r2, caps.Output), "r2 missing after batch replace")
|
|
|
|
// Replacing with a narrower set drops what it omits.
|
|
require.NoError(t, b.ReplaceRulesBatch(ctx, zone, []*Rule{r1}))
|
|
rules = rulesOf(t, ctx, mgr, zone)
|
|
require.True(t, containsRule(rules, r1, caps.Output), "r1 must survive the narrower replace")
|
|
require.False(t, containsRule(rules, r2, caps.Output), "r2 must be dropped by the narrower replace")
|
|
|
|
// An empty set clears the managed rules.
|
|
require.NoError(t, b.ReplaceRulesBatch(ctx, zone, nil))
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), r1, caps.Output),
|
|
"replacing with an empty set must clear the managed rules")
|
|
})
|
|
|
|
// --- 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. Gated on
|
|
// RuleOrdering: a backend whose filter rules form a first-match chain
|
|
// advertises it, while the list/zone-model backends (csf, apf, firewalld,
|
|
// wf) do not order rules this way.
|
|
requireCap(t, caps.RuleOrdering)
|
|
ports := []uint16{4101, 4102, 4103}
|
|
var rules []*Rule
|
|
for _, p := range ports {
|
|
r := &Rule{Family: IPv4, Proto: TCP, Port: p, Action: Accept}
|
|
rules = append(rules, r)
|
|
require.NoError(t, mgr.AddRule(ctx, zone, r))
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
|
|
}
|
|
|
|
before := managedPorts(t, ctx, mgr, zone, ports)
|
|
require.Len(t, before, 3, "all three rules should be present before backup")
|
|
|
|
backup, err := mgr.Backup(ctx, zone)
|
|
require.NoError(t, err)
|
|
for _, r := range rules {
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, r))
|
|
}
|
|
require.NoError(t, mgr.Restore(ctx, zone, backup))
|
|
|
|
after := managedPorts(t, ctx, mgr, zone, ports)
|
|
require.Equal(t, before, after, "Restore must preserve the backed-up rule order")
|
|
})
|
|
|
|
t.Run("backupstate", func(t *testing.T) {
|
|
// Backup must capture — and Restore must reconstruct — more than filter/NAT
|
|
// rules: the default policy and the managed address sets. 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("zones", func(t *testing.T) {
|
|
requireCap(t, caps.Zones)
|
|
// A zones backend maps interfaces to zones: GetZone with no interface
|
|
// names the default zone, an interface bound to another zone (seeded out
|
|
// of band, permanent config only) resolves to that zone, and an unbound
|
|
// interface keeps resolving to the default. The binding targets the
|
|
// trusted zone so an accidental runtime activation cannot filter
|
|
// anything away.
|
|
def, err := mgr.GetZone(ctx, "")
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, def, "a zones backend must name its default zone")
|
|
|
|
seeder := zoneInterfaceSeeder(mgr)
|
|
if seeder == nil {
|
|
t.Skip("no out-of-band interface-to-zone seeder for this backend")
|
|
}
|
|
target := "trusted"
|
|
if def == target {
|
|
target = "work"
|
|
}
|
|
undo, err := seeder("lo", target)
|
|
if err != nil {
|
|
t.Skipf("could not bind an interface to zone %q: %v", target, err)
|
|
}
|
|
t.Cleanup(undo)
|
|
|
|
got, err := mgr.GetZone(ctx, "lo")
|
|
require.NoError(t, err)
|
|
require.Equal(t, target, got, "GetZone must resolve a bound interface to its zone")
|
|
|
|
unbound, err := mgr.GetZone(ctx, "gofwit0")
|
|
require.NoError(t, err)
|
|
require.Equal(t, def, unbound, "an unbound interface must resolve to the default zone")
|
|
})
|
|
|
|
t.Run("rulecounters", func(t *testing.T) {
|
|
requireCap(t, caps.RuleCounters)
|
|
// A backend advertising RuleCounters must populate Packets/Bytes from the
|
|
// live firewall. Drive real traffic through a managed rule: an egress
|
|
// accept toward a TEST-NET address, dialed with a short timeout — the SYN
|
|
// leaves through the real interface, so it traverses the egress path even
|
|
// where loopback is exempt from filtering (the FreeBSD harness pf.conf
|
|
// skips lo0). Counting on an egress rule needs the direction, and every
|
|
// RuleCounters backend also distinguishes output.
|
|
requireCap(t, caps.Output)
|
|
r := &Rule{Direction: DirOutput, Family: IPv4, Proto: TCP, Port: 39321, Action: Accept}
|
|
require.NoError(t, mgr.AddRule(ctx, zone, r))
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, r) })
|
|
// Activate the rule where the backend programs the kernel on reload
|
|
// (iptables' save files load through its restore service).
|
|
require.NoError(t, mgr.Reload(ctx))
|
|
|
|
counted := func() bool {
|
|
for _, got := range rulesOf(t, ctx, mgr, zone) {
|
|
if got.EqualBase(r, mgr.Capabilities().Output) && got.Packets > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
// The dial itself fails — nothing answers a TEST-NET address — and only
|
|
// the attempt matters: each SYN that leaves must hit the rule's counter.
|
|
// Retry until the deadline, since a backend may report counters with some
|
|
// latency.
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for !counted() {
|
|
conn, derr := net.DialTimeout("tcp", "192.0.2.1:39321", 500*time.Millisecond)
|
|
if derr == nil {
|
|
_ = conn.Close()
|
|
}
|
|
require.False(t, time.Now().After(deadline),
|
|
"the managed rule's Packets counter never became non-zero after driving traffic at it")
|
|
}
|
|
})
|
|
|
|
t.Run("zonescope", func(t *testing.T) {
|
|
// A zone-scoped backend keeps a separate rule space per zone — firewalld's
|
|
// zones, wf's firewall profiles. AddRule and GetRules scope to the named
|
|
// zone, so RemoveRule must too: removing a rule from one zone must not
|
|
// delete an identical rule in another. The scoping is probed rather than
|
|
// gated on a backend: the first candidate zone name the backend accepts is
|
|
// the base, and a second qualifies only if the base zone's rule is not
|
|
// already visible there — a backend with one shared rule space (everything
|
|
// but firewalld and wf) never finds a distinct second zone and skips.
|
|
r := &Rule{Proto: TCP, Port: 5303, Action: Accept}
|
|
candidates := []string{"public", "private", "home", "work", "domain", "internal", "dmz"}
|
|
|
|
var zoneA, zoneB string
|
|
for i, z := range candidates {
|
|
if err := mgr.AddRule(ctx, z, r); err != nil {
|
|
continue
|
|
}
|
|
zoneA = z
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zoneA, r) })
|
|
for _, z2 := range candidates[i+1:] {
|
|
rs, err := mgr.GetRules(ctx, z2)
|
|
if err != nil || containsRule(rs, r, caps.Output) {
|
|
continue // an unknown zone, or one sharing zoneA's rule space.
|
|
}
|
|
if err := mgr.AddRule(ctx, z2, r); err != nil {
|
|
continue
|
|
}
|
|
zoneB = z2
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zoneB, r) })
|
|
break
|
|
}
|
|
break
|
|
}
|
|
if zoneA == "" || zoneB == "" {
|
|
t.Skip("backend does not scope rules to two distinct zones")
|
|
}
|
|
|
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneA), r, caps.Output), "rule should be present in zone %q", zoneA)
|
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneB), r, caps.Output), "rule should be present in zone %q", zoneB)
|
|
|
|
// Remove from the second zone only; the first zone's copy must survive.
|
|
require.NoError(t, mgr.RemoveRule(ctx, zoneB, r))
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zoneB), r, caps.Output), "rule should be gone from zone %q", zoneB)
|
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zoneA), r, caps.Output),
|
|
"removing the rule from zone %q must not delete the copy in zone %q", zoneB, zoneA)
|
|
})
|
|
|
|
t.Run("foreignmacsource", func(t *testing.T) {
|
|
// A backend may store a MAC zone source (firewalld), which GetRules reports
|
|
// as a bare-source rule. RemoveRule's source shortcut 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. The
|
|
// generic foreign-rule sweep is the foreignrule subtest; the seeding
|
|
// commands are inherently backend-specific (foreignMACSeeder), so a backend
|
|
// without a seeder skips, exactly like foreignrule.
|
|
seeder := foreignMACSeeder(mgr)
|
|
if seeder == nil {
|
|
t.Skip("no out-of-band MAC zone-source seeder for this backend")
|
|
}
|
|
seed, err := seeder(zone)
|
|
if err != nil {
|
|
t.Skipf("could not seed a foreign MAC source: %v", err)
|
|
}
|
|
t.Cleanup(seed.undo)
|
|
|
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
|
|
"a foreign MAC zone source should surface in GetRules")
|
|
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule))
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
|
|
"RemoveRule must remove a foreign MAC zone source")
|
|
})
|
|
|
|
t.Run("foreignprotocol", func(t *testing.T) {
|
|
// A backend may store a bare-protocol allow as its own construct distinct
|
|
// from the rule form the library writes (firewalld's zone protocol entries,
|
|
// seeded with firewall-cmd --add-protocol). GetRules must surface it and
|
|
// RemoveRule must remove it, or a foreign protocol allow is invisible to
|
|
// Sync/Restore. Like foreignmacsource, the seeding is backend-specific
|
|
// (foreignProtocolSeeder) and a backend without a seeder skips.
|
|
seeder := foreignProtocolSeeder(mgr)
|
|
if seeder == nil {
|
|
t.Skip("no out-of-band protocol-entry seeder for this backend")
|
|
}
|
|
seed, err := seeder(zone)
|
|
if err != nil {
|
|
t.Skipf("could not seed a foreign protocol: %v", err)
|
|
}
|
|
t.Cleanup(seed.undo)
|
|
|
|
require.True(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
|
|
"a foreign zone protocol should surface in GetRules")
|
|
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, seed.rule))
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), seed.rule, mgr.Capabilities().Output),
|
|
"RemoveRule must remove a foreign zone protocol")
|
|
})
|
|
|
|
t.Run("afterrulesexcluded", func(t *testing.T) {
|
|
// A backend may keep a raw rules file it deliberately does not manage (ufw's
|
|
// after.rules; the library writes raw rules only into before.rules). GetRules
|
|
// must not surface a rule from it — otherwise Backup captures it and Restore
|
|
// re-adds it into the managed file, duplicating it. The seeding edits the
|
|
// backend's own file (unmanagedRawRuleSeeder), so a backend without one skips.
|
|
seeder := unmanagedRawRuleSeeder(mgr)
|
|
if seeder == nil {
|
|
t.Skip("backend has no unmanaged raw rules file to seed")
|
|
}
|
|
probe, undo, err := seeder()
|
|
if err != nil {
|
|
t.Skipf("could not seed the unmanaged raw rule: %v", err)
|
|
}
|
|
t.Cleanup(undo)
|
|
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), probe, mgr.Capabilities().Output),
|
|
"a rule in an unmanaged raw rules file must not be surfaced by GetRules")
|
|
})
|
|
}
|
|
|
|
// --- helpers ----------------------------------------------------------------
|
|
|
|
// requireCap skips the current subtest when the backend does not advertise the
|
|
// feature under test.
|
|
func requireCap(t *testing.T, supported bool) {
|
|
t.Helper()
|
|
if !supported {
|
|
t.Skip("feature not supported by this backend")
|
|
}
|
|
}
|
|
|
|
// roundTripRule adds a rule, confirms it reads back, removes it, and confirms it
|
|
// is gone. A t.Cleanup guards against a mid-test failure leaving the rule behind.
|
|
func roundTripRule(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
|
|
t.Helper()
|
|
require.NoError(t, mgr.AddRule(ctx, zone, rule))
|
|
roundTripAdded(t, ctx, mgr, zone, rule)
|
|
}
|
|
|
|
// roundTripRuleOrSkip is roundTripRule for a shape not every backend can express:
|
|
// a rejection with the ErrUnsupported sentinel skips the subtest instead of
|
|
// failing it. A skip marks a real expressiveness gap — a feature the backend
|
|
// might expose (or a capability that should advertise it) — while any other
|
|
// failure is still a bug.
|
|
func roundTripRuleOrSkip(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
|
|
t.Helper()
|
|
err := mgr.AddRule(ctx, zone, rule)
|
|
if errors.Is(err, ErrUnsupported) {
|
|
t.Skipf("backend cannot express this shape: %v", err)
|
|
}
|
|
require.NoError(t, err)
|
|
roundTripAdded(t, ctx, mgr, zone, rule)
|
|
}
|
|
|
|
// roundTripAdded finishes a round trip for a rule AddRule already accepted: it
|
|
// must read back, remove, and read back as gone.
|
|
func roundTripAdded(t *testing.T, ctx context.Context, mgr Manager, zone string, rule *Rule) {
|
|
t.Helper()
|
|
t.Cleanup(func() { _ = mgr.RemoveRule(ctx, zone, rule) })
|
|
|
|
rules := rulesOf(t, ctx, mgr, zone)
|
|
require.True(t, containsRule(rules, rule, mgr.Capabilities().Output), "added rule %+v not found in %s", rule, dumpRules(rules))
|
|
|
|
require.NoError(t, mgr.RemoveRule(ctx, zone, rule))
|
|
require.False(t, containsRule(rulesOf(t, ctx, mgr, zone), rule, mgr.Capabilities().Output), "rule still present after removal")
|
|
}
|
|
|
|
// roundTripVariants tries each rule form in order and round-trips the first one
|
|
// the backend accepts, skipping a form the backend rejects with an ErrUnsupported
|
|
// sentinel. It lets a single probe cover backends that express the same
|
|
// capability in different forms — e.g. nft matches a global connection limit while
|
|
// apf only expresses a per-source one, and nft matches bare ICMP while apf
|
|
// requires a type. It fails if the backend rejects every form as unsupported,
|
|
// since the capability under test claimed the feature works.
|
|
func roundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) {
|
|
t.Helper()
|
|
if !tryRoundTripVariants(t, ctx, mgr, zone, variants...) {
|
|
t.Fatal("backend advertised the capability but rejected every probe form as unsupported")
|
|
}
|
|
}
|
|
|
|
// roundTripVariantsOrSkip is roundTripVariants for a shape family no capability
|
|
// guards: a backend that rejects every form with the ErrUnsupported sentinel
|
|
// skips the subtest instead of failing it.
|
|
func roundTripVariantsOrSkip(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) {
|
|
t.Helper()
|
|
if !tryRoundTripVariants(t, ctx, mgr, zone, variants...) {
|
|
t.Skip("backend cannot express this shape in any probed form")
|
|
}
|
|
}
|
|
|
|
// tryRoundTripVariants round-trips the first variant the backend accepts and
|
|
// reports whether any was accepted. A variant rejected with an ErrUnsupported
|
|
// sentinel moves on to the next; any other add error fails the test.
|
|
func tryRoundTripVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants ...*Rule) bool {
|
|
t.Helper()
|
|
for _, rule := range variants {
|
|
err := mgr.AddRule(ctx, zone, rule)
|
|
if errors.Is(err, ErrUnsupported) {
|
|
continue // the backend cannot express this form; try the next.
|
|
}
|
|
require.NoError(t, err)
|
|
roundTripAdded(t, ctx, mgr, zone, rule)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// roundTripNATVariants tries each NAT rule form and round-trips the first the
|
|
// backend accepts (not rejected with ErrUnsupportedNAT), mirroring
|
|
// roundTripVariants for filter rules. It skips the kind when the backend accepts
|
|
// no form (e.g. pf has no standalone Redirect).
|
|
func roundTripNATVariants(t *testing.T, ctx context.Context, mgr Manager, zone string, variants []*NATRule) {
|
|
t.Helper()
|
|
for _, nat := range variants {
|
|
err := mgr.AddNATRule(ctx, zone, nat)
|
|
if errors.Is(err, ErrUnsupportedNAT) {
|
|
continue
|
|
}
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = mgr.RemoveNATRule(ctx, zone, nat) })
|
|
|
|
rules, err := mgr.GetNATRules(ctx, zone)
|
|
require.NoError(t, err)
|
|
require.True(t, containsNAT(rules, nat), "added NAT rule %+v not found in %+v", nat, rules)
|
|
|
|
require.NoError(t, mgr.RemoveNATRule(ctx, zone, nat))
|
|
rules, err = mgr.GetNATRules(ctx, zone)
|
|
require.NoError(t, err)
|
|
require.False(t, containsNAT(rules, nat), "NAT rule still present after removal")
|
|
return
|
|
}
|
|
t.Skipf("%s does not support the %s NAT kind in any probed form", mgr.Type(), variants[0].Kind)
|
|
}
|
|
|
|
// rulesOf reads the managed rules or fails the test.
|
|
func rulesOf(t *testing.T, ctx context.Context, mgr Manager, zone string) []*Rule {
|
|
t.Helper()
|
|
rules, err := mgr.GetRules(ctx, zone)
|
|
require.NoError(t, err)
|
|
return rules
|
|
}
|
|
|
|
// 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
|
|
}
|