1379 lines
64 KiB
Go
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)
|
|
}
|