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

1379 lines
64 KiB
Go

package firewall
import (
"bytes"
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// impliedFamily must fall back to a concrete source/destination address when the
// family is unset and no ICMP protocol pins it, mirroring NATRule.impliedFamily.
// An IPv4-only rule left FamilyAny must not be treated as touching both families
// (which sends an IPv4 address into the ip6tables save file, rejected on load).
func TestRuleImpliedFamilyFromAddress(t *testing.T) {
require.Equal(t, IPv4, (&Rule{Source: "192.168.0.1"}).impliedFamily())
require.Equal(t, IPv6, (&Rule{Destination: "2001:db8::1"}).impliedFamily())
require.Equal(t, IPv4, (&Rule{Source: "10.0.0.0/8"}).impliedFamily())
// An explicit family always wins over an address hint.
require.Equal(t, IPv6, (&Rule{Family: IPv6, Source: "192.168.0.1"}).impliedFamily())
// An ICMP protocol still pins the family before any address is consulted.
require.Equal(t, IPv4, (&Rule{Proto: ICMP}).impliedFamily())
// No family, no proto, no address stays ambiguous.
require.Equal(t, FamilyAny, (&Rule{}).impliedFamily())
}
// ParseICMPType resolves a numeric or named ICMP type, selecting the ICMPv6 name
// table when v6 is set so a name shared with ICMPv4 maps to its v6 number. It is
// the exported resolver the CLI relies on, so its name coverage must match the
// tables the backends emit.
func TestParseICMPType(t *testing.T) {
cases := []struct {
tok string
v6 bool
want uint8
ok bool
}{
{"8", false, 8, true}, // numeric parses in either family
{"255", true, 255, true}, // numbers are family-independent
{"echo-request", false, 8, true},
{"ECHO-REQUEST", false, 8, true}, // case-insensitive
{"echo-request", true, 128, true}, // same name, different v6 number
{"destination-unreachable", false, 3, true},
{"destination-unreachable", true, 1, true},
{"nd-neighbor-solicit", true, 135, true},
// info-request/info-reply live only in the v4 table; the CLI used to omit
// them, so route them through here to prove they resolve.
{"info-request", false, 15, true},
{"info-reply", false, 16, true},
// A v4-only name is unknown under v6, and vice versa.
{"source-quench", true, 0, false},
{"packet-too-big", false, 0, false},
{"not-a-type", false, 0, false},
}
for _, c := range cases {
got, ok := ParseICMPType(c.tok, c.v6)
require.Equalf(t, c.ok, ok, "ParseICMPType(%q, v6=%v) ok", c.tok, c.v6)
if c.ok {
require.Equalf(t, c.want, got, "ParseICMPType(%q, v6=%v)", c.tok, c.v6)
}
}
}
// syncRecorder is a Manager with an empty existing rule set that records the
// rules Sync adds, so the diff logic can be exercised without a live firewall.
type syncRecorder struct {
restoreRecorder
added []*Rule
}
func (m *syncRecorder) AddRule(_ context.Context, _ string, r *Rule) error {
m.added = append(m.added, r)
return nil
}
// Sync must not queue two identical desired rules for a double add: the second
// is equal to the first and, against a RuleBatcher, both would be seen as new.
func TestSyncSkipsDuplicateDesired(t *testing.T) {
m := &syncRecorder{}
desired := []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
}
added, removed, err := Sync(context.Background(), m, "", desired)
require.NoError(t, err)
require.Equal(t, 0, removed)
require.Equal(t, 1, added, "two identical desired rules must be added once")
require.Len(t, m.added, 1)
}
// syncDirRecorder is a Manager with a fixed existing rule set that records the
// rules Sync adds and removes, so the DirAny reconcile can be exercised offline.
type syncDirRecorder struct {
restoreRecorder
existing []*Rule
added []*Rule
removed []*Rule
}
func (m *syncDirRecorder) GetRules(context.Context, string) ([]*Rule, error) {
return m.existing, nil
}
func (m *syncDirRecorder) AddRule(_ context.Context, _ string, r *Rule) error {
m.added = append(m.added, r)
return nil
}
func (m *syncDirRecorder) RemoveRule(_ context.Context, _ string, r *Rule) error {
m.removed = append(m.removed, r)
return nil
}
// Sync must not churn a DirAny rule: whether the desired set lists it as one DirAny
// rule or as the two separate directions, mergeDirectionsCopy canonicalizes it to
// match the merged existing rule, so nothing is added or removed.
func TestSyncDirAnyIdempotent(t *testing.T) {
existing := func() []*Rule {
return []*Rule{{Direction: DirAny, Source: "1.2.3.4", Action: Accept}}
}
// Desired lists the single DirAny rule.
m := &syncDirRecorder{existing: existing()}
added, removed, err := Sync(context.Background(), m, "", []*Rule{{Direction: DirAny, Source: "1.2.3.4", Action: Accept}})
require.NoError(t, err)
require.Zero(t, added)
require.Zero(t, removed)
require.Empty(t, m.added)
require.Empty(t, m.removed)
// Desired lists the two directions separately; they collapse to DirAny and match.
m2 := &syncDirRecorder{existing: existing()}
added, removed, err = Sync(context.Background(), m2, "", []*Rule{
{Direction: DirInput, Source: "1.2.3.4", Action: Accept},
{Direction: DirOutput, Destination: "1.2.3.4", Action: Accept},
})
require.NoError(t, err)
require.Zero(t, added, "a DirInput+DirOutput desired pair collapses to DirAny and must not churn")
require.Zero(t, removed)
require.Empty(t, m2.added)
require.Empty(t, m2.removed)
}
func TestPortNeedsConcreteProtocol(t *testing.T) {
cases := []struct {
rule Rule
want bool
}{
{Rule{Port: 80, Proto: TCP}, false},
{Rule{Port: 80, Proto: UDP}, false},
{Rule{Port: 80, Proto: ProtocolAny}, true},
{Rule{Port: 0, Proto: ProtocolAny}, false},
}
for _, c := range cases {
require.Equal(t, c.want, c.rule.PortNeedsConcreteProtocol(),
"PortNeedsConcreteProtocol(%+v)", c.rule)
}
}
// HasPrefix is informational, not part of rule identity: two rules that differ
// only in HasPrefix compare equal, so it never affects dedup or removal. The same
// holds for NAT rules.
func TestEqualIgnoresHasPrefix(t *testing.T) {
a := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, HasPrefix: true}
b := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, HasPrefix: false}
require.True(t, a.Equal(b, true), "Rule.Equal must ignore HasPrefix")
require.True(t, a.EqualBase(b, true), "Rule.EqualBase must ignore HasPrefix")
na := &NATRule{Kind: Masquerade, HasPrefix: true}
nb := &NATRule{Kind: Masquerade, HasPrefix: false}
require.True(t, na.Equal(nb), "NATRule.Equal must ignore HasPrefix")
require.True(t, na.EqualBase(nb), "NATRule.EqualBase must ignore HasPrefix")
}
// isSetRef treats any non-empty Source/Destination token that is not an IP or
// CIDR (after an optional "!" negation) as an address-set reference.
func TestIsSetRef(t *testing.T) {
for _, a := range []string{"", "!", "1.2.3.4", "10.0.0.0/8", "!1.2.3.4", "2001:db8::1", "!2001:db8::/32"} {
require.False(t, isSetRef(a), "%q should not be a set reference", a)
}
for _, s := range []string{"myset", "!myset", "blocklist", "trusted_hosts"} {
require.True(t, isSetRef(s), "%q should be a set reference", s)
}
}
// Number is informational like HasPrefix: two rules that differ only in their
// read-back ordering position compare equal, so it never affects dedup or removal.
func TestEqualIgnoresNumber(t *testing.T) {
a := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Number: 1}
b := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Number: 7}
require.True(t, a.Equal(b, true), "Rule.Equal must ignore Number")
require.True(t, a.EqualBase(b, true), "Rule.EqualBase must ignore Number")
na := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Number: 1}
nb := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Number: 3}
require.True(t, na.Equal(nb), "NATRule.Equal must ignore Number")
require.True(t, na.EqualBase(nb), "NATRule.EqualBase must ignore Number")
}
// The numbering helpers assign a rule's 1-based position within its ordering
// domain: per direction for chain-ordered backends, sequential for single-list
// backends, and per nat chain for NAT rules.
func TestNumberingHelpers(t *testing.T) {
byDir := []*Rule{{Port: 1}, {Port: 2, Direction: DirOutput}, {Port: 3}, {Port: 4, Direction: DirOutput},
{Port: 5, Direction: DirForward}, {Port: 6, Direction: DirForward}}
numberByDirection(byDir)
require.Equal(t, []int{1, 1, 2, 2, 1, 2},
[]int{byDir[0].Number, byDir[1].Number, byDir[2].Number, byDir[3].Number, byDir[4].Number, byDir[5].Number},
"input, output and forward chains are numbered independently from 1")
seq := []*Rule{{Port: 1}, {Port: 2, Direction: DirOutput}, {Port: 3}}
numberSequential(seq)
require.Equal(t, []int{1, 2, 3}, []int{seq[0].Number, seq[1].Number, seq[2].Number},
"a single ordered list numbers across directions")
nat := []*NATRule{{Kind: DNAT}, {Kind: SNAT}, {Kind: Redirect}, {Kind: Masquerade}}
numberNATByChain(nat)
require.Equal(t, []int{1, 1, 2, 2}, []int{nat[0].Number, nat[1].Number, nat[2].Number, nat[3].Number},
"prerouting (dnat/redirect) and postrouting (snat/masquerade) number independently")
natSeq := []*NATRule{{Kind: DNAT}, {Kind: SNAT}, {Kind: Redirect}}
numberNATSequential(natSeq)
require.Equal(t, []int{1, 2, 3}, []int{natSeq[0].Number, natSeq[1].Number, natSeq[2].Number})
}
// A backend that edits a physical, merge-collapsed list (nft chains, pf's anchor,
// ufw's numbered list) must translate a caller's merged position into the physical
// index of that logical rule's anchor row. The naive position-1 lands too early —
// inside a merged IPv4/IPv6 pair — once such a pair precedes the target.
func TestMergedInsertIndex(t *testing.T) {
// Raw (unmerged) list: an ssh IPv4/IPv6 pair that mergeFamilies collapses into
// one logical rule, followed by an IPv4-only https rule.
raw := []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Family: IPv6, Proto: TCP, Port: 22, Action: Accept},
{Family: IPv4, Proto: TCP, Port: 443, Action: Accept},
}
anchors := mergedFamilyAnchors(raw)
require.Equal(t, []int{0, 2}, anchors, "ssh pair collapses to anchor 0; https is anchor 2")
// Insert before the https rule, which GetRules numbers as merged position 2.
// The physical index must be 2 (before the https row), not the naive 1 (which
// would split the ssh IPv4/IPv6 pair).
require.Equal(t, 2, mergedInsertIndex(anchors, len(raw), 2))
require.NotEqual(t, 2-1, mergedInsertIndex(anchors, len(raw), 2),
"the merged position must not be used as a raw index")
// Position 1 prepends (physical index 0); a position past the end appends.
require.Equal(t, 0, mergedInsertIndex(anchors, len(raw), 1))
require.Equal(t, len(raw), mergedInsertIndex(anchors, len(raw), 3))
require.Equal(t, len(raw), mergedInsertIndex(anchors, len(raw), 99))
require.Equal(t, 0, mergedInsertIndex(anchors, len(raw), 0), "a non-positive position prepends")
// With no merged pair, every row is its own anchor and the mapping is identity.
flat := []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Family: IPv4, Proto: TCP, Port: 80, Action: Accept},
{Family: IPv4, Proto: TCP, Port: 443, Action: Accept},
}
fa := mergedFamilyAnchors(flat)
for pos := 1; pos <= len(flat); pos++ {
require.Equal(t, pos-1, mergedInsertIndex(fa, len(flat), pos))
}
}
// Rule identity compares addresses semantically: a bare host, its /32 (or /128)
// form, and a differently spelled but equal IPv6 address are the same address.
// Backends re-spell addresses on read (nft strips a /32, iptables-save adds it),
// so an exact string compare would report the rule as changed on every reconcile.
func TestAddrEqualCanonicalizesHostPrefix(t *testing.T) {
require.True(t, addrEqual("1.2.3.4", "1.2.3.4/32"))
require.True(t, addrEqual("2001:db8::1", "2001:0db8::1/128"))
require.True(t, addrEqual("10.0.0.5/24", "10.0.0.0/24"), "host bits are masked to the network")
require.True(t, addrEqual("!1.2.3.4", "!1.2.3.4/32"), "negation is preserved")
require.False(t, addrEqual("1.2.3.4", "1.2.3.5"))
require.False(t, addrEqual("1.2.3.4", "!1.2.3.4/32"), "a negation must not match its non-negated form")
require.False(t, addrEqual("", "0.0.0.0/0"), "an any-address is not the empty match")
require.False(t, addrEqual("myset", "myotherset"), "non-IP tokens compare verbatim")
a := &Rule{Family: IPv4, Source: "1.2.3.4/32", Proto: TCP, Port: 22, Action: Accept}
b := &Rule{Family: IPv4, Source: "1.2.3.4", Proto: TCP, Port: 22, Action: Accept}
require.True(t, a.EqualBase(b, true), "a /32 host and its bare form are the same rule")
}
// Family is compared through impliedFamily, so a rule left FamilyAny matches the
// concrete family its content forces (an ICMP proto, or a concrete-family
// address) — the family a backend stores and lists it back under.
func TestEqualImpliedFamily(t *testing.T) {
// A FamilyAny ICMP rule equals the IPv4 rule a backend reads back.
anyICMP := &Rule{Proto: ICMP, Action: Accept}
v4ICMP := &Rule{Family: IPv4, Proto: ICMP, Action: Accept}
require.True(t, anyICMP.Equal(v4ICMP, true), "FamilyAny icmp must equal IPv4 icmp")
// A FamilyAny rule with a v4 address equals its IPv4 read-back.
anyAddr := &Rule{Source: "10.0.0.1", Proto: TCP, Port: 22, Action: Accept}
v4Addr := &Rule{Family: IPv4, Source: "10.0.0.1", Proto: TCP, Port: 22, Action: Accept}
require.True(t, anyAddr.Equal(v4Addr, true), "FamilyAny address rule must equal its IPv4 form")
// A genuinely family-agnostic rule (no address, no ICMP) stays distinct from a
// single-family one.
anyTCP := &Rule{Proto: TCP, Port: 22, Action: Accept}
v4TCP := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
require.False(t, anyTCP.Equal(v4TCP, true), "an addressless FamilyAny rule is not a single-family rule")
require.False(t, v4ICMP.Equal(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}, true))
}
// NATRule.EqualBase must canonicalize ToAddress the same way it does Source and
// Destination, so a translation target re-spelled by a backend (an IPv6 zero-
// compressed/lower-cased address, a /32 host prefix) still dedups and removes.
func TestNATRuleEqualBaseCanonicalizesToAddress(t *testing.T) {
a := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:DB8::1", ToPort: 8080}
b := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080}
require.True(t, a.EqualBase(b), "differing IPv6 spellings of ToAddress are the same NAT rule")
c := &NATRule{Kind: SNAT, Proto: TCP, ToAddress: "1.2.3.4"}
d := &NATRule{Kind: SNAT, Proto: TCP, ToAddress: "1.2.3.4/32"}
require.True(t, c.EqualBase(d), "a /32 host and its bare form are the same translation target")
require.False(t, a.EqualBase(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::2", ToPort: 8080}))
}
// NATRule.Equal compares family through impliedFamily, so a FamilyAny rule
// matches the concrete family a backend stores it under (mirroring Rule.Equal).
func TestNATRuleEqualImpliedFamily(t *testing.T) {
anyRule := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2", ToPort: 80}
v4Rule := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "192.168.1.2", ToPort: 80}
require.True(t, anyRule.Equal(v4Rule), "a FamilyAny DNAT equals the IPv4 rule its target implies")
}
// NATRule.validate must reject a translation port on a rule without a port-
// bearing protocol: iptables' DNAT/REDIRECT/SNAT and nft's dnat/redirect reject
// a target port without tcp/udp/sctp, so it must not marshal such a rule.
func TestNATRuleValidate(t *testing.T) {
require.NoError(t, (&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}).validate())
require.NoError(t, (&NATRule{Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080}).validate())
require.NoError(t, (&NATRule{Kind: SNAT, ToAddress: "1.2.3.4"}).validate())
require.NoError(t, (&NATRule{Kind: Masquerade}).validate())
// DNAT needs a translation address.
require.Error(t, (&NATRule{Kind: DNAT}).validate())
// Redirect needs a port and no address.
require.Error(t, (&NATRule{Kind: Redirect}).validate())
require.Error(t, (&NATRule{Kind: Redirect, ToPort: 80, ToAddress: "1.2.3.4"}).validate())
// SNAT needs an address and no port.
require.Error(t, (&NATRule{Kind: SNAT}).validate())
require.Error(t, (&NATRule{Kind: SNAT, ToAddress: "1.2.3.4", ToPort: 80}).validate())
// Masquerade takes no target.
require.Error(t, (&NATRule{Kind: Masquerade, ToAddress: "1.2.3.4"}).validate())
// A match port without a concrete protocol is rejected.
require.Error(t, (&NATRule{Kind: DNAT, Port: 80, ToAddress: "1.2.3.4"}).validate())
// A translation port (ToPort) also needs a concrete protocol.
require.Error(t, (&NATRule{Kind: Redirect, ToPort: 8080}).validate())
require.NoError(t, (&NATRule{Kind: Redirect, Proto: TCP, ToPort: 8080}).validate())
require.Error(t, (&NATRule{Kind: DNAT, ToAddress: "1.2.3.4", ToPort: 80}).validate())
require.NoError(t, (&NATRule{Kind: DNAT, Proto: UDP, ToAddress: "1.2.3.4", ToPort: 80}).validate())
require.NoError(t, (&NATRule{Kind: DNAT, ToAddress: "1.2.3.4"}).validate())
}
// Priority is part of rule identity: two rules differing only in priority are
// distinct, so a reconcile can change a rule's priority (firewalld rich rules).
func TestEqualDistinguishesPriority(t *testing.T) {
a := &Rule{Priority: 10, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
b := &Rule{Priority: 20, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
require.False(t, a.Equal(b, false), "rules differing only in priority must not be equal")
require.False(t, a.EqualBase(b, false))
}
// Direction is part of rule identity when the backend honors it, and collapses
// when it does not — so a forward rule is distinct from its input/output twins on
// an ordered backend but folds into them on a direction-agnostic one.
func TestEqualDistinguishesDirection(t *testing.T) {
in := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
out := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Direction: DirOutput}
fwd := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Direction: DirForward}
// Honored: every direction is a distinct rule.
require.False(t, in.Equal(out, true), "input and output must differ when direction is honored")
require.False(t, in.Equal(fwd, true), "input and forward must differ when direction is honored")
require.False(t, out.Equal(fwd, true), "output and forward must differ when direction is honored")
// Not honored: direction is ignored, so all three compare equal.
require.True(t, in.Equal(fwd, false), "direction is ignored when not honored")
require.True(t, out.Equal(fwd, false), "direction is ignored when not honored")
// EqualBase ignores family but still honors direction when asked.
require.False(t, in.EqualBase(fwd, true))
require.True(t, in.EqualBase(fwd, false))
}
// ErrUnsupportedForward is a member of the ErrUnsupported family so a caller can
// tell a genuinely unexpressible forward rule from a real failure.
func TestErrUnsupportedForward(t *testing.T) {
require.ErrorIs(t, ErrUnsupportedForward, ErrUnsupported)
require.ErrorIs(t, unsupportedForward("test"), ErrUnsupportedForward)
require.ErrorIs(t, unsupportedForward("test"), ErrUnsupported)
}
func TestMergeFamilies(t *testing.T) {
// A genuine IPv4/IPv6 pair that is otherwise identical collapses to one
// FamilyAny rule.
merged := mergeFamilies([]*Rule{
{Family: IPv4, Port: 80, Proto: TCP, Action: Accept},
{Family: IPv6, Port: 80, Proto: TCP, Action: Accept},
})
require.Len(t, merged, 1, "expected a v4/v6 pair to merge into 1 rule")
require.Equal(t, FamilyAny, merged[0].Family, "expected merged rule to be FamilyAny")
// Two identical same-family rules must NOT be collapsed into FamilyAny; a
// duplicate within a single family is not cross-family coverage.
merged = mergeFamilies([]*Rule{
{Family: IPv4, Port: 80, Proto: TCP, Action: Accept},
{Family: IPv4, Port: 80, Proto: TCP, Action: Accept},
})
for _, r := range merged {
require.NotEqual(t, FamilyAny, r.Family,
"same-family duplicate was wrongly merged into FamilyAny: %+v", merged)
}
}
// DirInput must remain the zero value and "any"/"both" must parse to DirAny so a
// caller and the JSON/backup round-trip agree on the new direction.
func TestParseDirectionAny(t *testing.T) {
require.Equal(t, DirInput, Direction(0), "DirInput must stay the zero value")
require.Equal(t, "any", DirAny.String())
for _, tok := range []string{"any", "Any", "both", "BOTH"} {
d, err := ParseDirection(tok)
require.NoErrorf(t, err, "ParseDirection(%q)", tok)
require.Equalf(t, DirAny, d, "ParseDirection(%q)", tok)
}
// Round-trip every direction through String -> ParseDirection.
for _, d := range []Direction{DirInput, DirOutput, DirForward, DirAny} {
got, err := ParseDirection(d.String())
require.NoError(t, err)
require.Equal(t, d, got, "direction %v must round-trip through its string", d)
}
}
// directionSwapped swaps exactly the source/destination-bound fields and leaves
// every protocol/policy-bound field untouched.
func TestDirectionSwapped(t *testing.T) {
in := &Rule{
Direction: DirInput, Family: IPv4, Proto: TCP,
Source: "10.0.0.1", Destination: "10.0.0.2",
Port: 22, SourcePort: 1024,
Ports: []PortRange{{Start: 80, End: 90}},
SourcePorts: []PortRange{{Start: 5000, End: 5000}},
InInterface: "eth0", OutInterface: "eth1",
State: StateEstablished, Action: Accept, ICMPType: Ptr[uint8](8),
Log: true, LogPrefix: "x", Comment: "c",
}
s := in.directionSwapped()
require.Equal(t, "10.0.0.2", s.Source)
require.Equal(t, "10.0.0.1", s.Destination)
require.Equal(t, uint16(1024), s.Port)
require.Equal(t, uint16(22), s.SourcePort)
require.Equal(t, in.SourcePorts, s.Ports)
require.Equal(t, in.Ports, s.SourcePorts)
require.Equal(t, "eth1", s.InInterface)
require.Equal(t, "eth0", s.OutInterface)
// Direction-independent fields are preserved.
require.Equal(t, in.Proto, s.Proto)
require.Equal(t, in.State, s.State)
require.Equal(t, in.Action, s.Action)
require.Equal(t, in.ICMPType, s.ICMPType)
require.Equal(t, in.Log, s.Log)
require.Equal(t, in.Comment, s.Comment)
// The original is not mutated (shallow copy).
require.Equal(t, "10.0.0.1", in.Source)
// Swap is an involution.
require.True(t, in.EqualBase(s.directionSwapped(), true), "swapping twice returns the original")
}
// coversDirection (asymmetric, add/dedup) and coversDirectionRemoval (symmetric):
// DirAny spans input+output, never forward; !honored means direction never gates.
func TestCoversDirection(t *testing.T) {
// Asymmetric: an existing rule in `have` covers a caller in `want`.
require.True(t, coversDirection(DirAny, DirInput, true))
require.True(t, coversDirection(DirAny, DirOutput, true))
require.False(t, coversDirection(DirAny, DirForward, true), "DirAny never covers forward")
require.True(t, coversDirection(DirInput, DirInput, true))
require.False(t, coversDirection(DirInput, DirOutput, true))
require.False(t, coversDirection(DirInput, DirAny, true), "a concrete direction must not cover DirAny")
require.True(t, coversDirection(DirInput, DirOutput, false), "!honored: direction never gates")
// Symmetric: DirAny on either side touches a concrete direction.
require.True(t, coversDirectionRemoval(DirInput, DirAny, true))
require.True(t, coversDirectionRemoval(DirAny, DirOutput, true))
require.False(t, coversDirectionRemoval(DirInput, DirOutput, true))
require.False(t, coversDirectionRemoval(DirAny, DirForward, true))
require.True(t, coversDirectionRemoval(DirForward, DirForward, true))
}
// mergeDirections collapses an input rule and its role-swapped output twin into one
// DirAny rule, honoring family and never merging a forward rule.
func TestMergeDirections(t *testing.T) {
// A csf-style host allow: inbound Source=X + outbound Destination=X collapse to
// one DirAny rule in the inbound frame (Source=X).
merged := mergeDirections([]*Rule{
{Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Action: Accept},
{Direction: DirOutput, Family: IPv4, Destination: "1.2.3.4", Action: Accept},
})
require.Len(t, merged, 1, "in+out host allow must merge to 1 rule")
require.Equal(t, DirAny, merged[0].Direction)
require.Equal(t, "1.2.3.4", merged[0].Source)
require.Empty(t, merged[0].Destination)
// A ported service both ways: dport 22 inbound pairs with sport 22 outbound.
merged = mergeDirections([]*Rule{
{Direction: DirInput, Proto: TCP, Port: 22, Action: Accept},
{Direction: DirOutput, Proto: TCP, SourcePort: 22, Action: Accept},
})
require.Len(t, merged, 1, "dport-in + sport-out must merge")
require.Equal(t, DirAny, merged[0].Direction)
require.Equal(t, uint16(22), merged[0].Port)
// A forward rule must never merge, even if a swap-equal partner exists.
merged = mergeDirections([]*Rule{
{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept},
{Direction: DirOutput, Proto: TCP, SourcePort: 22, Action: Accept},
})
require.Len(t, merged, 2, "forward rules never participate in the direction merge")
// A cross-family pair must not merge across direction (v4-in with v6-out).
merged = mergeDirections([]*Rule{
{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept},
})
require.Len(t, merged, 2, "an opposite-family in/out pair must not merge")
}
// A rule present as {v4-in, v6-in, v4-out, v6-out} collapses, after mergeFamilies
// then mergeDirections, to a single FamilyAny + DirAny rule.
func TestMergeFamiliesThenDirectionsFourRows(t *testing.T) {
rows := []*Rule{
{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Direction: DirInput, Family: IPv6, Proto: TCP, Port: 22, Action: Accept},
{Direction: DirOutput, Family: IPv4, Proto: TCP, SourcePort: 22, Action: Accept},
{Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept},
}
rows = mergeFamilies(rows)
numberByDirection(rows)
rows = mergeDirections(rows)
require.Len(t, rows, 1, "the 4-cell rule must collapse to one")
require.Equal(t, FamilyAny, rows[0].Family)
require.Equal(t, DirAny, rows[0].Direction)
require.Equal(t, uint16(22), rows[0].Port)
}
// A rule that is symmetric under the swap (Source==Destination, no ports) still
// merges its in/out pair, and the surviving DirAny rule is unchanged by the swap.
func TestMergeDirectionsSymmetric(t *testing.T) {
merged := mergeDirections([]*Rule{
{Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Destination: "1.2.3.4", Action: Accept},
{Direction: DirOutput, Family: IPv4, Source: "1.2.3.4", Destination: "1.2.3.4", Action: Accept},
})
require.Len(t, merged, 1)
require.Equal(t, DirAny, merged[0].Direction)
}
// expandDirections fans a DirAny rule into an inbound row plus a swapped outbound
// row, and leaves a concrete-direction rule alone.
func TestExpandDirections(t *testing.T) {
rows := expandDirections(&Rule{Direction: DirAny, Family: IPv4, Source: "1.2.3.4", Port: 22, Proto: TCP, Action: Accept})
require.Len(t, rows, 2)
require.Equal(t, DirInput, rows[0].Direction)
require.Equal(t, "1.2.3.4", rows[0].Source)
require.Equal(t, uint16(22), rows[0].Port)
require.Equal(t, DirOutput, rows[1].Direction)
require.Equal(t, "1.2.3.4", rows[1].Destination)
require.Equal(t, uint16(22), rows[1].SourcePort)
single := expandDirections(&Rule{Direction: DirInput, Port: 80, Action: Accept})
require.Len(t, single, 1)
require.Equal(t, DirInput, single[0].Direction)
}
// splitDualRowDirection returns the surviving-direction rule when one direction of
// a genuine DirAny row is removed, and nil for a concrete matched row or a
// direction-agnostic target.
func TestSplitDualRowDirection(t *testing.T) {
any := &Rule{Direction: DirAny, Family: IPv4, Source: "1.2.3.4", Action: Accept}
// Remove the input cell: the output survives, in its natural (destination) frame.
surv := splitDualRowDirection(any, &Rule{Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Action: Accept})
require.NotNil(t, surv)
require.Equal(t, DirOutput, surv.Direction)
require.Equal(t, "1.2.3.4", surv.Destination)
require.Empty(t, surv.Source)
// Remove the output cell: the input survives unchanged.
surv = splitDualRowDirection(any, &Rule{Direction: DirOutput, Family: IPv4, Destination: "1.2.3.4", Action: Accept})
require.NotNil(t, surv)
require.Equal(t, DirInput, surv.Direction)
require.Equal(t, "1.2.3.4", surv.Source)
// A concrete matched row never splits (its twin is a separate physical row).
require.Nil(t, splitDualRowDirection(&Rule{Direction: DirInput, Source: "1.2.3.4"}, &Rule{Direction: DirInput, Source: "1.2.3.4"}))
// A direction-agnostic target removes the whole rule (no survivor).
require.Nil(t, splitDualRowDirection(any, &Rule{Direction: DirAny, Source: "1.2.3.4"}))
}
// A DirAny row must be found by a concrete-direction removal target and vice versa,
// through EqualForRemoval, honoring the swap. And a DirAny existing rule dedups a
// concrete add.
func TestEqualForDirectionCoverage(t *testing.T) {
anyRow := &Rule{Direction: DirAny, Family: IPv4, Source: "1.2.3.4", Action: Accept}
inTarget := &Rule{Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Action: Accept}
outTarget := &Rule{Direction: DirOutput, Family: IPv4, Destination: "1.2.3.4", Action: Accept}
require.True(t, anyRow.EqualForRemoval(inTarget, true), "a DirAny row matches an input target")
require.True(t, anyRow.EqualForRemoval(outTarget, true), "a DirAny row matches an output target (swapped)")
require.True(t, anyRow.EqualForDedup(inTarget, true), "a DirAny row dedups an input add")
require.True(t, anyRow.EqualForDedup(outTarget, true), "a DirAny row dedups an output add (swapped)")
// A concrete input row must NOT dedup a DirAny add (it would leave output uncovered).
require.False(t, inTarget.EqualForDedup(anyRow, true), "an input row must not cover a DirAny add")
// A forward target must not be touched by a DirAny row.
require.False(t, anyRow.EqualForRemoval(&Rule{Direction: DirForward, Family: IPv4, Source: "1.2.3.4", Action: Accept}, true))
}
// dirAnyInputFallback degrades a DirAny rule to its input half on a backend with no
// output concept, and leaves every other case unchanged.
func TestDirAnyInputFallback(t *testing.T) {
any := &Rule{Direction: DirAny, Source: "1.2.3.4", Action: Accept}
// No output support: DirAny collapses to DirInput, fields preserved, original
// untouched.
got := dirAnyInputFallback(any, false)
require.Equal(t, DirInput, got.Direction)
require.Equal(t, "1.2.3.4", got.Source)
require.Equal(t, DirAny, any.Direction, "the input rule must not be mutated")
// Output support: DirAny is left alone (the backend fans it out instead).
require.Equal(t, DirAny, dirAnyInputFallback(any, true).Direction)
// A concrete-direction rule is always returned unchanged.
in := &Rule{Direction: DirInput, Source: "1.2.3.4", Action: Accept}
require.Same(t, in, dirAnyInputFallback(in, false))
}
// Enums render as their stable string name (not a bare number) and round-trip
// through encoding/json. A backup must stay readable and meaningful even if an
// iota constant is later reordered.
func TestEnumJSON(t *testing.T) {
cases := []struct {
name string
in any
want string // the quoted JSON string expected
}{
{"action-accept", Accept, `"accept"`},
{"action-drop", Drop, `"drop"`},
{"action-invalid", ActionInvalid, `"invalid"`},
{"family-v4", IPv4, `"ipv4"`},
{"family-v6", IPv6, `"ipv6"`},
{"family-any", FamilyAny, `"any"`},
{"proto-tcp", TCP, `"tcp"`},
{"proto-sctp", SCTP, `"sctp"`},
{"proto-any", ProtocolAny, `"any"`},
{"natkind", DNAT, `"dnat"`},
{"rateunit", PerMinute, `"minute"`},
{"direction", DirForward, `"forward"`},
{"settype", SetHashNet, `"hash:net"`},
{"connstate", ConnState(StateEstablished | StateRelated), `"established,related"`},
{"connstate-zero", ConnState(0), `""`},
}
for _, c := range cases {
out, err := json.Marshal(c.in)
require.NoError(t, err, c.name)
require.Equal(t, c.want, string(out), "%s: marshal", c.name)
}
// Round-trip each value through marshal -> unmarshal.
roundTrips := []struct {
name string
mk func() any // fresh addressable value to unmarshal into
eq func(any) bool // reports whether it equals the marshal source
}{
{"action", func() any { var v Action; return &v }, func(g any) bool { return *g.(*Action) == Accept }},
{"family", func() any { var v Family; return &v }, func(g any) bool { return *g.(*Family) == IPv4 }},
{"proto", func() any { var v Protocol; return &v }, func(g any) bool { return *g.(*Protocol) == TCP }},
{"connstate", func() any { var v ConnState; return &v }, func(g any) bool { return *g.(*ConnState) == (StateNew | StateEstablished) }},
{"rateunit", func() any { var v RateUnit; return &v }, func(g any) bool { return *g.(*RateUnit) == PerHour }},
{"natkind", func() any { var v NATKind; return &v }, func(g any) bool { return *g.(*NATKind) == Masquerade }},
{"direction", func() any { var v Direction; return &v }, func(g any) bool { return *g.(*Direction) == DirOutput }},
{"settype", func() any { var v SetType; return &v }, func(g any) bool { return *g.(*SetType) == SetHashIP }},
}
marshalVals := map[string]any{
"action": Accept,
"family": IPv4,
"proto": TCP,
"connstate": ConnState(StateNew | StateEstablished),
"rateunit": PerHour,
"natkind": Masquerade,
"direction": DirOutput,
"settype": SetHashIP,
}
for _, rt := range roundTrips {
data, err := json.Marshal(marshalVals[rt.name])
require.NoError(t, err, rt.name)
dst := rt.mk()
require.NoError(t, json.Unmarshal(data, dst), "%s: unmarshal %s", rt.name, data)
require.True(t, rt.eq(dst), "%s: round-trip mismatch (got %+v)", rt.name, dst)
}
}
// An unknown enum token in a backup fails to decode rather than silently
// becoming a wrong value.
func TestEnumJSONUnknownRejects(t *testing.T) {
var a Action
err := json.Unmarshal([]byte(`"bogus"`), &a)
require.Error(t, err)
}
// A Backup serializes to portable JSON and decodes back identically, including
// enum fields, pointers, port lists, limits and comments.
func TestBackupJSONRoundTrip(t *testing.T) {
icmpType := uint8(8)
original := &Backup{
Rules: []*Rule{
{
Direction: DirOutput, Priority: 5, Family: IPv4,
Source: "10.0.0.0/8", Destination: "!192.168.1.5",
Port: 443, Ports: []PortRange{{Start: 8000, End: 8100}, {Start: 9000, End: 9000}},
SourcePort: 53,
Proto: TCP, State: StateNew | StateEstablished,
InInterface: "eth0", OutInterface: "eth1",
Action: Accept, Log: true, LogPrefix: "https",
RateLimit: &RateLimit{Rate: 20, Unit: PerSecond, Burst: 10},
ConnLimit: &ConnLimit{Count: 100, PerSource: true},
Comment: "ingress",
},
{Family: IPv6, Proto: ICMPv6, ICMPType: &icmpType, Action: Drop},
{Family: FamilyAny, Proto: GRE, Action: Reject},
},
NATRules: []*NATRule{
{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 8080, ToAddress: "10.0.0.5", ToPort: 80, Interface: "eth0"},
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
{Kind: Redirect, Family: IPv4, Proto: UDP, Port: 5353, ToPort: 5353},
},
// A direction left ActionInvalid (Forward here) must survive the round-trip
// as "invalid" so SetDefaultPolicy leaves it unchanged on restore.
DefaultPolicy: &DefaultPolicy{Input: Drop, Output: Accept, Forward: ActionInvalid},
AddressSets: []*AddressSet{
{Name: "blocklist", Family: IPv4, Type: SetHashNet, Entries: []string{"192.0.2.0/24", "198.51.100.0/24"}},
{Name: "allow6", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}},
},
}
var buf bytes.Buffer
require.NoError(t, WriteBackup(&buf, original))
// The encoding carries the stable names, not bare numbers.
require.Contains(t, buf.String(), `"accept"`)
require.Contains(t, buf.String(), `"ipv4"`)
require.Contains(t, buf.String(), `"dnat"`)
require.Contains(t, buf.String(), `"hash:net"`) // set type as a stable name
require.Contains(t, buf.String(), `"invalid"`) // ActionInvalid policy direction
require.NotContains(t, buf.String(), `"Family":1`) // no numeric family encoding
got, err := ReadBackup(&buf)
require.NoError(t, err)
require.Len(t, got.Rules, len(original.Rules))
require.Len(t, got.NATRules, len(original.NATRules))
for i := range original.Rules {
require.True(t, reflect.DeepEqual(original.Rules[i], got.Rules[i]),
"rule %d: want %+v got %+v", i, original.Rules[i], got.Rules[i])
}
for i := range original.NATRules {
require.True(t, reflect.DeepEqual(original.NATRules[i], got.NATRules[i]),
"nat rule %d: want %+v got %+v", i, original.NATRules[i], got.NATRules[i])
}
require.True(t, reflect.DeepEqual(original.DefaultPolicy, got.DefaultPolicy),
"default policy: want %+v got %+v", original.DefaultPolicy, got.DefaultPolicy)
require.True(t, reflect.DeepEqual(original.AddressSets, got.AddressSets),
"address sets: want %+v got %+v", original.AddressSets, got.AddressSets)
}
// WriteBackup rejects a nil backup; ReadBackup rejects malformed input.
func TestBackupJSONErrors(t *testing.T) {
require.Error(t, WriteBackup(&bytes.Buffer{}, nil))
_, err := ReadBackup(strings.NewReader("not json"))
require.Error(t, err)
}
// restoreRecorder is a minimal Manager whose Restore captures the backup handed
// to it, so RestoreReader can be exercised without a live firewall.
type restoreRecorder struct {
restored *Backup
restore func(*Backup) error
}
func (m *restoreRecorder) Type() string { return "recorder" }
func (m *restoreRecorder) Capabilities() Capabilities {
return Capabilities{Output: true}
}
func (m *restoreRecorder) GetZone(context.Context, string) (string, error) { return "", nil }
func (m *restoreRecorder) GetRules(context.Context, string) ([]*Rule, error) {
return nil, nil
}
func (m *restoreRecorder) AddRule(context.Context, string, *Rule) error { return nil }
func (m *restoreRecorder) InsertRule(context.Context, string, int, *Rule) error { return nil }
func (m *restoreRecorder) MoveRule(context.Context, string, *Rule, int) error { return nil }
func (m *restoreRecorder) RemoveRule(context.Context, string, *Rule) error { return nil }
func (m *restoreRecorder) GetNATRules(context.Context, string) ([]*NATRule, error) {
return nil, nil
}
func (m *restoreRecorder) AddNATRule(context.Context, string, *NATRule) error { return nil }
func (m *restoreRecorder) InsertNATRule(context.Context, string, int, *NATRule) error { return nil }
func (m *restoreRecorder) RemoveNATRule(context.Context, string, *NATRule) error { return nil }
func (m *restoreRecorder) Backup(context.Context, string) (*Backup, error) { return nil, nil }
func (m *restoreRecorder) Restore(_ context.Context, _ string, b *Backup) error {
m.restored = b
if m.restore != nil {
return m.restore(b)
}
return nil
}
func (m *restoreRecorder) GetDefaultPolicy(context.Context, string) (*DefaultPolicy, error) {
return nil, nil
}
func (m *restoreRecorder) SetDefaultPolicy(context.Context, string, *DefaultPolicy) error {
return nil
}
func (m *restoreRecorder) GetAddressSets(context.Context) ([]*AddressSet, error) {
return nil, nil
}
func (m *restoreRecorder) GetAddressSet(context.Context, string) (*AddressSet, error) {
return nil, nil
}
func (m *restoreRecorder) AddAddressSet(context.Context, *AddressSet) error { return nil }
func (m *restoreRecorder) RemoveAddressSet(context.Context, string) error { return nil }
func (m *restoreRecorder) AddAddressSetEntry(context.Context, string, string) error {
return nil
}
func (m *restoreRecorder) RemoveAddressSetEntry(context.Context, string, string) error {
return nil
}
func (m *restoreRecorder) Reload(context.Context) error { return nil }
func (m *restoreRecorder) Close(context.Context) error { return nil }
// RestoreReader decodes a backup from the reader and hands it to Restore. A read
// error surfaces before Restore is ever called; a good payload reaches Restore
// intact.
func TestRestoreReader(t *testing.T) {
ctx := context.Background()
// A malformed reader: RestoreReader returns the read error without
// invoking Restore.
rec := &restoreRecorder{}
err := RestoreReader(ctx, rec, "", strings.NewReader("not json"))
require.Error(t, err)
require.Nil(t, rec.restored, "Restore must not run on a read error")
// A valid payload: Restore receives the decoded backup.
src := &Backup{Rules: []*Rule{{Family: IPv4, Port: 22, Proto: TCP, Action: Accept}}}
var buf bytes.Buffer
require.NoError(t, WriteBackup(&buf, src))
rec = &restoreRecorder{}
require.NoError(t, RestoreReader(ctx, rec, "public", &buf))
require.NotNil(t, rec.restored)
require.Len(t, rec.restored.Rules, 1)
require.Equal(t, uint16(22), rec.restored.Rules[0].Port)
}
// --- Bug-report fixes: core library ----------------------------------------
// ParseAction rejects the "invalid" sentinel as caller input so a rule or policy
// can never be authored with no real action, while Action.UnmarshalJSON still
// round-trips it for backup fidelity.
func TestParseActionRejectsInvalidSentinel(t *testing.T) {
for _, tok := range []string{"invalid", "INVALID", " invalid ", "bogus", ""} {
_, err := ParseAction(tok)
require.Error(t, err, "ParseAction(%q) must error", tok)
}
for tok, want := range map[string]Action{"accept": Accept, "reject": Reject, "drop": Drop} {
got, err := ParseAction(tok)
require.NoError(t, err)
require.Equal(t, want, got)
}
// The wire form of ActionInvalid still decodes, so a backup carrying it
// round-trips rather than failing to load.
var a Action
require.NoError(t, json.Unmarshal([]byte(`"invalid"`), &a))
require.Equal(t, ActionInvalid, a)
// An unknown token is still rejected on the wire.
require.Error(t, json.Unmarshal([]byte(`"bogus"`), &a))
}
// isSetRef must treat the "any" wildcard as an address, not a named set, so a
// backend never emits set-match syntax for the match-all token.
func TestIsSetRefTreatsAnyAsWildcard(t *testing.T) {
for _, a := range []string{"any", "!any", " any ", ""} {
require.False(t, isSetRef(a), "isSetRef(%q) must be false", a)
}
for _, a := range []string{"1.2.3.4", "10.0.0.0/8", "!192.168.1.1"} {
require.False(t, isSetRef(a), "isSetRef(%q) is an address", a)
}
for _, a := range []string{"myset", "!blocklist"} {
require.True(t, isSetRef(a), "isSetRef(%q) is a set reference", a)
}
}
// Protocol.UnmarshalJSON rejects an unknown protocol string rather than silently
// widening the rule to ProtocolAny; only "any" and empty resolve to ProtocolAny.
func TestProtocolUnmarshalRejectsUnknown(t *testing.T) {
var p Protocol
require.NoError(t, json.Unmarshal([]byte(`"tcp"`), &p))
require.Equal(t, TCP, p)
require.NoError(t, json.Unmarshal([]byte(`"any"`), &p))
require.Equal(t, ProtocolAny, p)
require.Error(t, json.Unmarshal([]byte(`"tpc"`), &p), "an unknown protocol must not decode")
require.Error(t, json.Unmarshal([]byte(`"bogus"`), &p))
}
// TestMergedNATInsertIndexMasqueradePair verifies the merged-position mapping an
// ordered NAT backend (pf, nft) uses for InsertNATRule: GetNATRules merges an
// IPv4/IPv6 masquerade pair into one logical rule, so a caller-supplied position
// must map back to the physical row before which to insert. Without the mapping a
// plain position-1 would splice a new rule into the middle of the merged pair.
func TestMergedNATInsertIndexMasqueradePair(t *testing.T) {
// Physical anchor order: masquerade IPv4, its IPv6 twin, then a DNAT rule.
masqV4 := &NATRule{Kind: Masquerade, Interface: "em0", Family: IPv4}
masqV6 := &NATRule{Kind: Masquerade, Interface: "em0", Family: IPv6}
dnat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", Family: IPv4}
physical := []*NATRule{masqV4, masqV6, dnat}
// GetNATRules collapses the masquerade pair, so it reports masq=Number 1,
// dnat=Number 2. Inserting at merged position 2 means "before the DNAT rule".
anchors := mergedNATFamilyAnchors(physical)
require.Equal(t, []int{0, 2}, anchors, "the masquerade twin at index 1 is absorbed")
idx := mergedInsertIndex(anchors, len(physical), 2)
require.Equal(t, 2, idx, "position 2 must map before the DNAT row (physical index 2), not split the masq pair")
// Position 1 maps before the masquerade anchor; a position past the end appends.
require.Equal(t, 0, mergedInsertIndex(anchors, len(physical), 1))
require.Equal(t, len(physical), mergedInsertIndex(anchors, len(physical), 3))
}
// TestEqualForDedupFamily locks the add-time dedup family-cover relation: an
// existing FamilyAny rule covers either family, and a concrete-family rule covers
// only its own. Without this, a base-equal existing rule of one family would
// wrongly suppress the add of its opposite-family twin (see the nft/csf
// cross-family bugs). Rules differ only in Family so the base compare always
// passes and the family relation is what is under test.
func TestEqualForDedupFamily(t *testing.T) {
base := func(fam Family) *Rule { return &Rule{Family: fam, Proto: TCP, Port: 22, Action: Drop} }
require.True(t, base(FamilyAny).EqualForDedup(base(IPv4), true), "FamilyAny covers IPv4")
require.True(t, base(FamilyAny).EqualForDedup(base(IPv6), true), "FamilyAny covers IPv6")
require.True(t, base(FamilyAny).EqualForDedup(base(FamilyAny), true), "FamilyAny covers FamilyAny")
require.True(t, base(IPv4).EqualForDedup(base(IPv4), true), "IPv4 covers IPv4")
require.True(t, base(IPv6).EqualForDedup(base(IPv6), true), "IPv6 covers IPv6")
require.False(t, base(IPv4).EqualForDedup(base(IPv6), true), "IPv4 must not cover IPv6")
require.False(t, base(IPv6).EqualForDedup(base(IPv4), true), "IPv6 must not cover IPv4")
require.False(t, base(IPv4).EqualForDedup(base(FamilyAny), true), "a concrete family must not cover FamilyAny")
// A rule whose base fields differ is never a duplicate, whatever the family.
require.False(t, base(FamilyAny).EqualForDedup(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop}, true),
"a different base rule is not a duplicate")
}
// syncStateMock returns a fixed existing set and records the removes/adds Sync
// issues, so the diff can be exercised against a pre-populated firewall.
type syncStateMock struct {
restoreRecorder
existing []*Rule
added []*Rule
removed []*Rule
}
func (m *syncStateMock) GetRules(context.Context, string) ([]*Rule, error) { return m.existing, nil }
func (m *syncStateMock) AddRule(_ context.Context, _ string, r *Rule) error {
m.added = append(m.added, r)
return nil
}
func (m *syncStateMock) RemoveRule(_ context.Context, _ string, r *Rule) error {
m.removed = append(m.removed, r)
return nil
}
// Sync must not churn when GetRules has merged an IPv4/IPv6 pair into one
// FamilyAny rule but the caller lists the two families separately: the merged
// existing rule already covers both desired twins, so nothing is added or removed.
func TestSyncMergedFamilyNoChurn(t *testing.T) {
merged := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop}
m := &syncStateMock{existing: []*Rule{merged}}
desired := []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Action: Drop},
{Family: IPv6, Proto: TCP, Port: 22, Action: Drop},
}
added, removed, err := Sync(context.Background(), m, "", desired)
require.NoError(t, err)
require.Equal(t, 0, removed, "the merged existing rule covers both twins; nothing removed")
require.Equal(t, 0, added, "the merged existing rule covers both twins; nothing added")
require.Empty(t, m.removed)
require.Empty(t, m.added)
require.NotNil(t, desired[0], "Sync must not mutate the caller's desired slice entries")
require.Equal(t, IPv4, desired[0].Family, "Sync must not mutate the caller's desired rules")
}
// TestEqualForRemovalFamily: a FamilyAny target (a merged rule) matches every
// row, a FamilyAny row matches any target, and concrete families must match
// otherwise. The receiver is the existing row; the argument is the target.
func TestEqualForRemovalFamily(t *testing.T) {
base := func(fam Family) *Rule { return &Rule{Family: fam, Proto: TCP, Port: 22, Action: Drop} }
require.True(t, base(IPv4).EqualForRemoval(base(FamilyAny), true), "a FamilyAny target matches an IPv4 row")
require.True(t, base(IPv6).EqualForRemoval(base(FamilyAny), true), "a FamilyAny target matches an IPv6 row")
require.True(t, base(FamilyAny).EqualForRemoval(base(IPv4), true), "an IPv4 target matches a FamilyAny row")
require.True(t, base(IPv4).EqualForRemoval(base(IPv4), true))
require.False(t, base(IPv6).EqualForRemoval(base(IPv4), true), "an IPv4 target must not touch an IPv6 row")
require.False(t, base(IPv4).EqualForRemoval(base(IPv6), true), "an IPv6 target must not touch an IPv4 row")
}
// The matcher pf and nft RemoveRule use — EqualForRemoval — must find a merged
// FamilyAny rule against both concrete physical rows, and a concrete-family
// target must match only its own row.
func TestMergedFamilyMatcherFindsBothRows(t *testing.T) {
v4 := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 22, Action: Accept}
merged := mergeFamilies([]*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Family: IPv6, Proto: TCP, Port: 22, Action: Accept},
})
require.Len(t, merged, 1)
require.Equal(t, FamilyAny, merged[0].impliedFamily())
// A merged (FamilyAny) target matches both rows.
require.True(t, v4.EqualForRemoval(merged[0], true))
require.True(t, v6.EqualForRemoval(merged[0], true))
// A concrete IPv4 target matches only the IPv4 row.
require.True(t, v4.EqualForRemoval(v4, true))
require.False(t, v6.EqualForRemoval(v4, true))
}
// TestSplitDualRow: a concrete-family removal against a genuine dual-family row
// yields the opposite-family rule to re-add, preserving the untargeted family;
// every other combination yields nil (no split).
func TestSplitDualRow(t *testing.T) {
dual := &Rule{Family: FamilyAny, Proto: TCP, Port: 3493, Action: Accept, Comment: "keep"}
// Removing IPv4 from a dual row leaves an IPv6 copy of the whole rule.
got := splitDualRow(dual, &Rule{Family: IPv4, Proto: TCP, Port: 3493, Action: Accept})
require.NotNil(t, got)
require.Equal(t, IPv6, got.Family)
require.Equal(t, "keep", got.Comment, "the surviving family keeps the original rule's fields")
require.Equal(t, FamilyAny, dual.Family, "the matched row is not mutated")
// Removing IPv6 from a dual row leaves an IPv4 copy.
got = splitDualRow(dual, &Rule{Family: IPv6, Proto: TCP, Port: 3493, Action: Accept})
require.NotNil(t, got)
require.Equal(t, IPv4, got.Family)
// A FamilyAny target removes the whole rule — no split.
require.Nil(t, splitDualRow(dual, &Rule{Family: FamilyAny, Proto: TCP, Port: 3493, Action: Accept}))
// A concrete-family matched row removes only itself — no split.
concrete := &Rule{Family: IPv4, Proto: TCP, Port: 3493, Action: Accept}
require.Nil(t, splitDualRow(concrete, &Rule{Family: IPv4, Proto: TCP, Port: 3493, Action: Accept}))
require.Equal(t, IPv6, oppositeFamily(IPv4))
require.Equal(t, IPv4, oppositeFamily(IPv6))
require.Equal(t, FamilyAny, oppositeFamily(FamilyAny))
}
// eqRateLimit treats an explicit netfilter default burst (5) as equal to an unset
// burst (0), since nft/iptables read the default back as 0; a non-default burst
// stays distinct.
func TestEqRateLimitBurstDefault(t *testing.T) {
a := &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}
b := &RateLimit{Rate: 10, Unit: PerMinute, Burst: 0}
require.True(t, eqRateLimit(a, b), "an explicit burst of 5 equals the default (0)")
c := &RateLimit{Rate: 10, Unit: PerMinute, Burst: 3}
require.False(t, eqRateLimit(a, c), "a non-default burst stays distinct")
}
// runCommand must pin LC_ALL=C so backend tools emit canonical English output and
// error strings, which several backends match to drive fallback logic (ufw's
// insert-append and idempotent-remove, CSF/APF restart handling). Guards the
// locale pinning in runCommandStdin against regression on a translated host.
func TestRunCommandPinsCLocale(t *testing.T) {
out, err := runCommand(context.Background(), "sh", "-c", "printf %s \"$LC_ALL\"")
require.NoError(t, err)
require.Equal(t, []string{"C"}, out, "runCommand must export LC_ALL=C to the child")
}
// A port set whose ranges are contiguous or overlapping must compare equal to
// the coalesced form a backend lists back (nft merges adjacent ranges on read),
// so rule identity is coalescing-invariant and Sync does not churn.
func TestPortRangesEqualCoalesces(t *testing.T) {
cases := []struct {
name string
a, b []PortRange
want bool
}{
{"contiguous singletons and range", []PortRange{{22, 22}, {23, 23}, {24, 30}}, []PortRange{{22, 30}}, true},
{"adjacent ranges merge", []PortRange{{100, 200}, {201, 300}}, []PortRange{{100, 300}}, true},
{"single plus adjacent range", []PortRange{{80, 80}, {81, 90}}, []PortRange{{80, 90}}, true},
{"overlapping ranges", []PortRange{{100, 200}, {150, 250}}, []PortRange{{100, 250}}, true},
{"order independence", []PortRange{{443, 443}, {80, 80}}, []PortRange{{80, 80}, {443, 443}}, true},
{"discrete singletons equal to their span", []PortRange{{80, 80}, {81, 81}}, []PortRange{{80, 81}}, true},
{"non-contiguous stays distinct", []PortRange{{80, 80}, {443, 443}}, []PortRange{{80, 443}}, false},
{"gap of one is not contiguous", []PortRange{{80, 80}, {82, 82}}, []PortRange{{80, 82}}, false},
{"top-of-range does not wrap", []PortRange{{65534, 65535}}, []PortRange{{65534, 65534}, {65535, 65535}}, true},
}
for _, c := range cases {
require.Equalf(t, c.want, portRangesEqual(c.a, c.b), "portRangesEqual(%v,%v)", c.a, c.b)
require.Equalf(t, c.want, portRangesEqual(c.b, c.a), "portRangesEqual is symmetric for %v,%v", c.a, c.b)
}
}
// coalescePortRanges yields the minimal sorted canonical list, so the merged
// form a backend reads back and the split form a caller wrote reduce to the same
// value.
func TestCoalescePortRanges(t *testing.T) {
require.Nil(t, coalescePortRanges(nil))
require.Equal(t,
[]PortRange{{22, 30}},
coalescePortRanges([]PortRange{{24, 30}, {22, 22}, {23, 23}}),
"contiguous ranges collapse to one span")
require.Equal(t,
[]PortRange{{80, 80}, {443, 443}},
coalescePortRanges([]PortRange{{443, 443}, {80, 80}}),
"non-contiguous ranges stay separate and sorted")
}
// portRangeInSpecs matches a range against a list by normalized value; it backs
// CSF's port-removal path.
func TestPortRangeInSpecs(t *testing.T) {
specs := []PortRange{{80, 80}, {1000, 2000}}
require.True(t, portRangeInSpecs(PortRange{Start: 80}, specs), "a bare single port matches its normalized form")
require.True(t, portRangeInSpecs(PortRange{Start: 1000, End: 2000}, specs), "a range matches exactly")
require.True(t, portRangeInSpecs(PortRange{Start: 80, End: 0}, specs), "a zero End normalizes to a single port")
require.False(t, portRangeInSpecs(PortRange{Start: 443}, specs), "an absent port does not match")
require.False(t, portRangeInSpecs(PortRange{Start: 1000, End: 1999}, specs), "a different range does not match")
}
func TestParseRateUnit(t *testing.T) {
cases := map[string]RateUnit{
"s": PerSecond, "sec": PerSecond, "second": PerSecond,
"m": PerMinute, "min": PerMinute, "minute": PerMinute,
"h": PerHour, "hour": PerHour,
"d": PerDay, "day": PerDay,
}
for in, want := range cases {
got, err := ParseRateUnit(in)
require.NoError(t, err, "unit %q", in)
require.Equal(t, want, got, "unit %q", in)
}
_, err := ParseRateUnit("fortnight")
require.Error(t, err)
}
func TestRateLimitString(t *testing.T) {
require.Equal(t, "10/minute", (&RateLimit{Rate: 10, Unit: PerMinute}).String())
require.Equal(t, "5/second", (&RateLimit{Rate: 5, Unit: PerSecond}).String())
}
func TestNATRuleEqualAndMerge(t *testing.T) {
a := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}
b := &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}
require.True(t, a.EqualBase(b), "same match, different family")
require.False(t, a.Equal(b), "Equal must honor family")
merged := mergeNATFamilies([]*NATRule{a, b})
require.Len(t, merged, 1, "an ipv4/ipv6 pair should merge")
require.Equal(t, FamilyAny, merged[0].Family)
}
func TestRuleLogLimitIdentity(t *testing.T) {
base := &Rule{Port: 22, Proto: TCP, Action: Accept}
logged := &Rule{Port: 22, Proto: TCP, Action: Accept, Log: true}
require.False(t, base.EqualBase(logged, true), "logging is part of rule identity")
limited := &Rule{Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}
require.False(t, base.EqualBase(limited, true), "a rate limit is part of rule identity")
same := &Rule{Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}
require.True(t, limited.EqualBase(same, true))
srcPort := &Rule{Port: 22, Proto: TCP, Action: Accept, SourcePort: 1234}
require.False(t, base.EqualBase(srcPort, true), "source port is part of rule identity")
}
// The added transport/tunnel protocols render and parse consistently, and only
// the port-carrying ones (tcp/udp/sctp) accept a port.
func TestProtocolExtras(t *testing.T) {
for _, p := range []Protocol{SCTP, GRE, ESP, AH} {
require.Equal(t, p, GetProtocol(p.String()), "round-trip %s", p)
}
require.Equal(t, ESP, GetProtocol("ipsec-esp"))
require.Equal(t, AH, GetProtocol("ipsec-ah"))
require.True(t, SCTP.HasPorts(), "sctp carries ports")
require.False(t, GRE.HasPorts(), "gre is portless")
require.False(t, ESP.HasPorts())
require.False(t, AH.HasPorts())
// A port is valid with SCTP but not with a portless protocol.
require.False(t, (&Rule{Proto: SCTP, Port: 9000}).PortNeedsConcreteProtocol())
require.True(t, (&Rule{Proto: GRE, Port: 9000}).PortNeedsConcreteProtocol())
}
// A comment is informational metadata: two rules that differ only in their
// comment are equal, and removal need not name the comment.
func TestCommentNotPartOfIdentity(t *testing.T) {
a := &Rule{Port: 22, Proto: TCP, Action: Accept, Comment: "ssh"}
b := &Rule{Port: 22, Proto: TCP, Action: Accept, Comment: "something else"}
c := &Rule{Port: 22, Proto: TCP, Action: Accept}
require.True(t, a.EqualBase(b, true))
require.True(t, a.Equal(b, true))
require.True(t, a.EqualBase(c, true))
}
// Direction/SetType string rendering is stable for the rule encoders.
func TestDirectionAndSetType(t *testing.T) {
require.Equal(t, "input", DirInput.String())
require.Equal(t, "output", DirOutput.String())
require.Equal(t, "forward", DirForward.String())
require.Equal(t, "hash:ip", SetHashIP.String())
require.Equal(t, "hash:net", SetHashNet.String())
}
// GetRules merges an IPv4/IPv6 pair into one FamilyAny rule and numbers the result,
// so a rule's Number counts logical (merged) rules. But nft chain edits act on the
// physical rows. mergedFamilyAnchors must map each merged rule back to its physical
// anchor so InsertRule/MoveRule place rules where the caller's Number says.
func TestMergedFamilyAnchorsMatchNumbering(t *testing.T) {
mk := func(fam Family, port uint16) *Rule {
return &Rule{Family: fam, Proto: TCP, Port: port, Action: Accept}
}
// Physical input chain: [R4(22), R6(22), C(80)] — R4/R6 are a v4/v6 pair.
phys := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(FamilyAny, 80)}
anchors := mergedFamilyAnchors(phys)
require.Equal(t, []int{0, 2}, anchors,
"the merged pair anchors at physical index 0; C stays at physical index 2")
// Cross-check against mergeFamilies + numberByDirection on an independent copy:
// each merged rule's Number must map through the anchors to its physical anchor.
cp := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(FamilyAny, 80)}
merged := mergeFamilies(cp)
numberByDirection(merged)
require.Len(t, merged, 2)
require.Equal(t, 1, merged[0].Number)
require.Equal(t, 0, anchors[merged[0].Number-1], "merged pair (Number 1) -> physical index 0")
require.Equal(t, 2, merged[1].Number)
require.Equal(t, 2, anchors[merged[1].Number-1], "C (Number 2) -> physical index 2")
}
// A twin that is not adjacent to its anchor (a non-matching rule sits between the
// v4 and v6 rows) must still be absorbed, and the anchor order preserved.
func TestMergedFamilyAnchorsNonAdjacentTwin(t *testing.T) {
phys := []*Rule{
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
{Proto: TCP, Port: 80, Action: Accept},
{Family: IPv6, Proto: TCP, Port: 22, Action: Accept},
}
anchors := mergedFamilyAnchors(phys)
require.Equal(t, []int{0, 1}, anchors,
"R4 anchors the pair at index 0; the port-80 rule stays at index 1; R6 is absorbed")
}
// The NAT anchor mapping mirrors the filter one.
func TestMergedNATFamilyAnchors(t *testing.T) {
phys := []*NATRule{
{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080},
{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080},
{Kind: DNAT, Proto: TCP, Port: 443, ToAddress: "10.0.0.6", ToPort: 8443},
}
anchors := mergedNATFamilyAnchors(phys)
require.Equal(t, []int{0, 2}, anchors)
require.Equal(t, 2, mergedInsertIndex(anchors, len(phys), 2),
"NAT insert before merged position 2 must target physical index 2")
}
// recordManager is a minimal Manager that records mutations and does NOT
// implement RuleBatcher, exercising the fallback loop in AddRules and the diff
// engine in Sync.
type recordManager struct {
rules []*Rule
added int
removed int
}
func (m *recordManager) Type() string { return "record" }
func (m *recordManager) Capabilities() Capabilities {
return Capabilities{Output: true}
}
func (m *recordManager) GetZone(ctx context.Context, iface string) (string, error) { return "", nil }
func (m *recordManager) GetRules(ctx context.Context, zone string) ([]*Rule, error) {
return m.rules, nil
}
func (m *recordManager) AddRule(ctx context.Context, zone string, r *Rule) error {
stored := *r
m.rules = append(m.rules, &stored)
m.added++
return nil
}
func (m *recordManager) InsertRule(ctx context.Context, zone string, pos int, r *Rule) error {
return unsupportedOrdering(m.Type())
}
func (m *recordManager) MoveRule(ctx context.Context, zone string, r *Rule, pos int) error {
return unsupportedOrdering(m.Type())
}
func (m *recordManager) RemoveRule(ctx context.Context, zone string, r *Rule) error {
for i, e := range m.rules {
if e.Equal(r, true) {
m.rules = append(m.rules[:i], m.rules[i+1:]...)
m.removed++
return nil
}
}
return nil
}
func (m *recordManager) GetNATRules(ctx context.Context, zone string) ([]*NATRule, error) {
return nil, unsupportedNAT(m.Type())
}
func (m *recordManager) AddNATRule(ctx context.Context, zone string, r *NATRule) error {
return unsupportedNAT(m.Type())
}
func (m *recordManager) InsertNATRule(ctx context.Context, zone string, position int, r *NATRule) error {
return unsupportedNAT(m.Type())
}
func (m *recordManager) RemoveNATRule(ctx context.Context, zone string, r *NATRule) error {
return unsupportedNAT(m.Type())
}
func (m *recordManager) Backup(ctx context.Context, zone string) (*Backup, error) {
return nil, nil
}
func (m *recordManager) Restore(ctx context.Context, zone string, b *Backup) error {
return nil
}
func (m *recordManager) GetDefaultPolicy(ctx context.Context, zone string) (*DefaultPolicy, error) {
return nil, unsupportedPolicy(m.Type())
}
func (m *recordManager) SetDefaultPolicy(ctx context.Context, zone string, p *DefaultPolicy) error {
return unsupportedPolicy(m.Type())
}
func (m *recordManager) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
return nil, unsupportedSet(m.Type())
}
func (m *recordManager) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
return nil, unsupportedSet(m.Type())
}
func (m *recordManager) AddAddressSet(ctx context.Context, s *AddressSet) error {
return unsupportedSet(m.Type())
}
func (m *recordManager) RemoveAddressSet(ctx context.Context, name string) error {
return unsupportedSet(m.Type())
}
func (m *recordManager) AddAddressSetEntry(ctx context.Context, name, entry string) error {
return unsupportedSet(m.Type())
}
func (m *recordManager) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
return unsupportedSet(m.Type())
}
func (m *recordManager) Reload(ctx context.Context) error { return nil }
func (m *recordManager) Close(ctx context.Context) error { return nil }
// A backend that does not implement RuleBatcher falls back to per-rule AddRule,
// and Sync computes the correct add/remove diff against it.
func TestAddRulesAndSyncFallback(t *testing.T) {
ctx := context.Background()
m := &recordManager{}
// Not a RuleBatcher: AddRules must loop AddRule.
_, isBatcher := interface{}(m).(RuleBatcher)
require.False(t, isBatcher)
require.NoError(t, AddRules(ctx, m, "", []*Rule{
{Port: 22, Proto: TCP, Action: Accept},
{Port: 80, Proto: TCP, Action: Accept},
}))
require.Equal(t, 2, m.added)
// Sync keeps 22, removes 80, adds 443.
added, removed, err := Sync(ctx, m, "", []*Rule{
{Port: 22, Proto: TCP, Action: Accept},
{Port: 443, Proto: TCP, Action: Accept},
})
require.NoError(t, err)
require.Equal(t, 1, added)
require.Equal(t, 1, removed)
require.Len(t, m.rules, 2)
}