Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged value spanning both transports, distinct from ProtocolAny (which matches every IP protocol and carries no port). Backends whose native syntax holds both transports in one row (nftables, ufw, apf) store and read it as one rule; the rest fan it out with expandProtocols. Removing one transport of a merged row splits it via splitMergedRow, which composes the family and protocol splits so an nftables row merged on both axes leaves a correct, non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT. Remove read-side merging. GetRules now reports the firewall's actual rows and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up separately-stored ones, so mergeFamilies, mergeDirections and their helpers are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the NATRule pair) expand a rule across family, transport and direction and decide containment cell by cell, which is what lets Sync stay a no-op against its own output whichever representation a backend chose. Extract the systemd/SysV service helpers out of the iptables backend into services.go so every Linux backend shares one implementation, and document the multi-state rule model and the coverage helpers in the README.
1691 lines
80 KiB
Go
1691 lines
80 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, whichever representation the backend chose: one
|
|
// that stores it as a single bidirectional object reports one DirAny rule, while one
|
|
// that fans it out reports an input rule plus a role-swapped output rule. Each is
|
|
// fully covered by the other, so nothing is added or removed either way.
|
|
func TestSyncDirAnyIdempotent(t *testing.T) {
|
|
dirAny := func() []*Rule {
|
|
return []*Rule{{Direction: DirAny, Source: "1.2.3.4", Action: Accept}}
|
|
}
|
|
fannedOut := func() []*Rule {
|
|
return []*Rule{
|
|
{Direction: DirInput, Source: "1.2.3.4", Action: Accept},
|
|
{Direction: DirOutput, Destination: "1.2.3.4", Action: Accept},
|
|
}
|
|
}
|
|
|
|
// The backend stored it as one DirAny row; desired names it the same way.
|
|
m := &syncDirRecorder{existing: dirAny()}
|
|
added, removed, err := Sync(context.Background(), m, "", dirAny())
|
|
require.NoError(t, err)
|
|
require.Zero(t, added)
|
|
require.Zero(t, removed)
|
|
require.Empty(t, m.added)
|
|
require.Empty(t, m.removed)
|
|
|
|
// The backend fanned it out into two concrete rows; desired still names one DirAny
|
|
// rule, whose cells those two rows cover between them.
|
|
m2 := &syncDirRecorder{existing: fannedOut()}
|
|
added, removed, err = Sync(context.Background(), m2, "", dirAny())
|
|
require.NoError(t, err)
|
|
require.Zero(t, added, "a fanned-out DirAny rule is covered by the DirAny desired rule")
|
|
require.Zero(t, removed)
|
|
require.Empty(t, m2.added)
|
|
require.Empty(t, m2.removed)
|
|
|
|
// Desired lists the two directions separately against a single stored DirAny row.
|
|
m3 := &syncDirRecorder{existing: dirAny()}
|
|
added, removed, err = Sync(context.Background(), m3, "", fannedOut())
|
|
require.NoError(t, err)
|
|
require.Zero(t, added, "the DirAny row covers each concrete desired direction")
|
|
require.Zero(t, removed, "and the two desired rules cover the DirAny row between them")
|
|
require.Empty(t, m3.added)
|
|
require.Empty(t, m3.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 whose physical list holds rows GetRules does not report as their own rule
|
|
// — pf's opaque foreign lines, ufw's unmodeled route tuples, iptables' LOG line folded
|
|
// into the action that follows it — must translate a caller's logical position into
|
|
// the physical index of that rule's row. The naive position-1 lands too early once
|
|
// such a row precedes the target.
|
|
func TestLogicalInsertIndex(t *testing.T) {
|
|
// Five physical rows where rows 1 and 3 are unmodeled, so the three reported rules
|
|
// live at physical indices 0, 2 and 4.
|
|
anchors := []int{0, 2, 4}
|
|
const physicalLen = 5
|
|
|
|
require.Equal(t, 0, logicalInsertIndex(anchors, physicalLen, 1))
|
|
require.Equal(t, 2, logicalInsertIndex(anchors, physicalLen, 2))
|
|
require.NotEqual(t, 2-1, logicalInsertIndex(anchors, physicalLen, 2),
|
|
"a logical position must not be used as a raw index")
|
|
require.Equal(t, 4, logicalInsertIndex(anchors, physicalLen, 3))
|
|
|
|
// A position past the last logical rule appends; a non-positive position prepends.
|
|
require.Equal(t, physicalLen, logicalInsertIndex(anchors, physicalLen, 4))
|
|
require.Equal(t, physicalLen, logicalInsertIndex(anchors, physicalLen, 99))
|
|
require.Equal(t, 0, logicalInsertIndex(anchors, physicalLen, 0), "a non-positive position prepends")
|
|
|
|
// With every row modeled, the mapping is the identity.
|
|
flat := []int{0, 1, 2}
|
|
for pos := 1; pos <= len(flat); pos++ {
|
|
require.Equal(t, pos-1, logicalInsertIndex(flat, 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))
|
|
}
|
|
|
|
// A backend that cannot store one family-agnostic row reports a FamilyAny rule as an
|
|
// IPv4 row plus an IPv6 row. The pair must cover the rule the caller authored, and a
|
|
// same-family duplicate must not — a duplicate within one family is not cross-family
|
|
// coverage, and reporting it as such would leave the other family unprotected.
|
|
func TestFamilyCoverage(t *testing.T) {
|
|
want := &Rule{Family: FamilyAny, Port: 80, Proto: TCP, Action: Accept}
|
|
|
|
require.True(t, want.CoveredBy([]*Rule{
|
|
{Family: IPv4, Port: 80, Proto: TCP, Action: Accept},
|
|
{Family: IPv6, Port: 80, Proto: TCP, Action: Accept},
|
|
}), "a v4/v6 pair covers the FamilyAny rule it materializes")
|
|
|
|
require.False(t, want.CoveredBy([]*Rule{
|
|
{Family: IPv4, Port: 80, Proto: TCP, Action: Accept},
|
|
{Family: IPv4, Port: 80, Proto: TCP, Action: Accept},
|
|
}), "a same-family duplicate must not be mistaken for cross-family coverage")
|
|
|
|
// A single family-agnostic row covers both halves on its own.
|
|
require.True(t, want.CoveredBy([]*Rule{
|
|
{Family: FamilyAny, Port: 80, Proto: TCP, Action: Accept},
|
|
}))
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// A backend that cannot store a bidirectional rule reports a DirAny rule as an input
|
|
// row plus its role-swapped output row. Coverage honors that swap, honors family, and
|
|
// never accepts a forward rule as one half of the pair.
|
|
func TestDirectionCoverage(t *testing.T) {
|
|
// A csf-style host allow: inbound Source=X plus outbound Destination=X cover the
|
|
// DirAny rule, which is authored in the inbound frame (Source=X).
|
|
hostAllow := &Rule{Direction: DirAny, Family: IPv4, Source: "1.2.3.4", Action: Accept}
|
|
require.True(t, hostAllow.CoveredBy([]*Rule{
|
|
{Direction: DirInput, Family: IPv4, Source: "1.2.3.4", Action: Accept},
|
|
{Direction: DirOutput, Family: IPv4, Destination: "1.2.3.4", Action: Accept},
|
|
}), "in+out host allow covers the DirAny rule")
|
|
|
|
// A ported service both ways: dport 22 inbound pairs with sport 22 outbound.
|
|
svc := &Rule{Direction: DirAny, Proto: TCP, Port: 22, Action: Accept}
|
|
require.True(t, svc.CoveredBy([]*Rule{
|
|
{Direction: DirInput, Proto: TCP, Port: 22, Action: Accept},
|
|
{Direction: DirOutput, Proto: TCP, SourcePort: 22, Action: Accept},
|
|
}), "dport-in + sport-out covers the DirAny rule")
|
|
|
|
// A forward rule is not the outbound half of anything: it has no in/out twin.
|
|
require.False(t, svc.CoveredBy([]*Rule{
|
|
{Direction: DirInput, Proto: TCP, Port: 22, Action: Accept},
|
|
{Direction: DirForward, Proto: TCP, SourcePort: 22, Action: Accept},
|
|
}), "a forward rule never covers the outbound half of a DirAny rule")
|
|
|
|
// An opposite-family outbound row leaves the IPv4 outbound cell uncovered.
|
|
v4svc := &Rule{Direction: DirAny, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
|
|
require.False(t, v4svc.CoveredBy([]*Rule{
|
|
{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
|
|
{Direction: DirOutput, Family: IPv6, Proto: TCP, SourcePort: 22, Action: Accept},
|
|
}), "an opposite-family outbound row must not cover the IPv4 outbound cell")
|
|
}
|
|
|
|
// A rule spanning family and direction has four cells, and a backend that can store
|
|
// neither axis reports it as {v4-in, v6-in, v4-out, v6-out}. All four together cover
|
|
// it; any three do not.
|
|
func TestFamilyAndDirectionCoverageFourRows(t *testing.T) {
|
|
want := &Rule{Direction: DirAny, Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept}
|
|
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},
|
|
}
|
|
require.True(t, want.CoveredBy(rows), "the four cells cover the rule")
|
|
require.False(t, want.CoveredBy(rows[:3]), "dropping the v6 outbound cell breaks coverage")
|
|
}
|
|
|
|
// A rule symmetric under the direction swap (Source==Destination, no ports) is
|
|
// covered by its in/out pair even though the swap is a no-op on it.
|
|
func TestDirectionCoverageSymmetric(t *testing.T) {
|
|
want := &Rule{Direction: DirAny, Family: IPv4, Source: "1.2.3.4", Destination: "1.2.3.4", Action: Accept}
|
|
require.True(t, want.CoveredBy([]*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},
|
|
}))
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
// A FamilyAny masquerade rule a backend stored as an IPv4 row plus an IPv6 row is
|
|
// still fully present, so a caller must not be told to add it again. Coverage decides
|
|
// this cell by cell, since no single row Covers the FamilyAny rule.
|
|
func TestNATFamilyCoverage(t *testing.T) {
|
|
masq := &NATRule{Kind: Masquerade, Interface: "em0", Family: FamilyAny}
|
|
require.True(t, masq.CoveredBy([]*NATRule{
|
|
{Kind: Masquerade, Interface: "em0", Family: IPv4},
|
|
{Kind: Masquerade, Interface: "em0", Family: IPv6},
|
|
}), "the v4/v6 pair covers the family-agnostic masquerade rule")
|
|
|
|
require.False(t, masq.CoveredBy([]*NATRule{
|
|
{Kind: Masquerade, Interface: "em0", Family: IPv4},
|
|
}), "the IPv4 row alone leaves the IPv6 cell uncovered")
|
|
|
|
// A single family-agnostic row covers both halves on its own.
|
|
require.True(t, masq.CoveredBy([]*NATRule{
|
|
{Kind: Masquerade, Interface: "em0", Family: FamilyAny},
|
|
}))
|
|
}
|
|
|
|
// 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 on the family axis whichever side is the merged one: a stored
|
|
// family-agnostic row covers a caller listing the two families separately, and a pair
|
|
// of stored family-pinned rows covers a caller naming one FamilyAny rule. The second
|
|
// case is the one every fan-out backend hits against its own output.
|
|
func TestSyncFamilyNoChurn(t *testing.T) {
|
|
// The backend stored one family-agnostic row; desired lists both families.
|
|
m := &syncStateMock{existing: []*Rule{{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop}}}
|
|
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 family-agnostic row covers both desired twins; nothing removed")
|
|
require.Equal(t, 0, added, "the family-agnostic row covers both desired 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")
|
|
|
|
// The backend fanned the rule out into a v4 row and a v6 row; desired names one
|
|
// FamilyAny rule. Neither row Covers it alone, but together they do.
|
|
m2 := &syncStateMock{existing: []*Rule{
|
|
{Family: IPv4, Proto: TCP, Port: 22, Action: Drop},
|
|
{Family: IPv6, Proto: TCP, Port: 22, Action: Drop},
|
|
}}
|
|
added, removed, err = Sync(context.Background(), m2, "", []*Rule{
|
|
{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, removed, "each stored row is covered by the FamilyAny desired rule")
|
|
require.Equal(t, 0, added, "the two stored rows cover the FamilyAny desired rule between them")
|
|
require.Empty(t, m2.removed)
|
|
require.Empty(t, m2.added)
|
|
}
|
|
|
|
// Sync removes an existing rule that desired only partially covers: the whole row
|
|
// goes, and the wanted part is added back. Here the backend holds one FamilyAny row
|
|
// but only IPv4 is wanted.
|
|
func TestSyncPartiallyCoveredRuleIsReplaced(t *testing.T) {
|
|
m := &syncStateMock{existing: []*Rule{{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop}}}
|
|
added, removed, err := Sync(context.Background(), m, "", []*Rule{
|
|
{Family: IPv4, Proto: TCP, Port: 22, Action: Drop},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, removed, "the FamilyAny row covers an unwanted IPv6 cell, so it is removed")
|
|
require.Equal(t, 1, added, "the wanted IPv4 rule is added back")
|
|
require.Len(t, m.removed, 1)
|
|
require.Equal(t, FamilyAny, m.removed[0].Family)
|
|
require.Len(t, m.added, 1)
|
|
require.Equal(t, IPv4, m.added[0].Family)
|
|
}
|
|
|
|
// 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 match a FamilyAny
|
|
// target against both concrete physical rows, so removing it clears every row it
|
|
// covers, while a concrete-family target matches only its own row.
|
|
func TestFamilyAnyRemovalMatchesBothRows(t *testing.T) {
|
|
v4 := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
|
|
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 22, Action: Accept}
|
|
any := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept}
|
|
|
|
// A FamilyAny target matches both rows.
|
|
require.True(t, v4.EqualForRemoval(any, true))
|
|
require.True(t, v6.EqualForRemoval(any, 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 TestNATRuleEqualAndCovers(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")
|
|
|
|
// A DNAT to an IPv4 address is an IPv4 rule whatever its Family field says, so
|
|
// coverage is decided on the implied family. Use a redirect, which names no
|
|
// address, to exercise the family-agnostic case.
|
|
anyRedir := &NATRule{Kind: Redirect, Family: FamilyAny, Proto: TCP, Port: 80, ToPort: 8080}
|
|
v4Redir := &NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}
|
|
v6Redir := &NATRule{Kind: Redirect, Family: IPv6, Proto: TCP, Port: 80, ToPort: 8080}
|
|
require.True(t, anyRedir.Covers(v4Redir), "a family-agnostic rule covers either family")
|
|
require.True(t, anyRedir.Covers(v6Redir))
|
|
require.False(t, v4Redir.Covers(anyRedir), "coverage is asymmetric")
|
|
require.False(t, v4Redir.Covers(v6Redir), "a concrete family covers only itself")
|
|
|
|
// The address pins the family, so the FamilyAny DNAT covers only its IPv4 twin.
|
|
anyDNAT := &NATRule{Kind: DNAT, Family: FamilyAny, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}
|
|
require.True(t, anyDNAT.Covers(a))
|
|
require.False(t, anyDNAT.Covers(b), "an IPv4 translation target makes the rule IPv4")
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
// GetRules reports one rule per physical row, so a rule's Number is its own row's
|
|
// rank within its chain. An IPv4 row and its IPv6 twin each get their own Number —
|
|
// nothing is collapsed — which is what keeps InsertRule/MoveRule positions aligned
|
|
// with the physical chain a backend edits.
|
|
func TestNumberByDirectionCountsEveryRow(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)].
|
|
phys := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(FamilyAny, 80)}
|
|
numberByDirection(phys)
|
|
require.Equal(t, []int{1, 2, 3}, []int{phys[0].Number, phys[1].Number, phys[2].Number},
|
|
"the v4/v6 twins each occupy their own position")
|
|
|
|
// Each chain is numbered independently, and a DirAny rule counts in the input chain.
|
|
chains := []*Rule{
|
|
{Direction: DirInput, Proto: TCP, Port: 22, Action: Accept},
|
|
{Direction: DirOutput, Proto: TCP, Port: 25, Action: Accept},
|
|
{Direction: DirAny, Proto: TCP, Port: 53, Action: Accept},
|
|
{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept},
|
|
{Direction: DirOutput, Proto: TCP, Port: 443, Action: Accept},
|
|
}
|
|
numberByDirection(chains)
|
|
require.Equal(t, 1, chains[0].Number, "first input rule")
|
|
require.Equal(t, 1, chains[1].Number, "first output rule")
|
|
require.Equal(t, 2, chains[2].Number, "DirAny numbers in the input chain")
|
|
require.Equal(t, 1, chains[3].Number, "first forward rule")
|
|
require.Equal(t, 2, chains[4].Number, "second output rule")
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// TestProtocolCoverage locks in the protocol axis: a same-family tcp/udp pair covers
|
|
// the TCPUDP rule it materializes, and nothing else does.
|
|
func TestProtocolCoverage(t *testing.T) {
|
|
ported := &Rule{Family: IPv4, Port: 53, Proto: TCPUDP, Action: Accept}
|
|
require.True(t, ported.CoveredBy([]*Rule{
|
|
{Family: IPv4, Port: 53, Proto: TCP, Action: Accept},
|
|
{Family: IPv4, Port: 53, Proto: UDP, Action: Accept},
|
|
}), "a tcp/udp pair covers the TCPUDP rule")
|
|
|
|
// A portless pair covers too: `-p tcp` plus `-p udp` is exactly TCPUDP. But
|
|
// ProtocolAny is not on the axis — it matches every IP protocol, so it neither
|
|
// covers nor is covered by TCPUDP; treating it as the pair's merge would widen
|
|
// the rule to icmp, gre and the rest.
|
|
portless := &Rule{Family: IPv4, Proto: TCPUDP, Action: Accept}
|
|
require.True(t, portless.CoveredBy([]*Rule{
|
|
{Family: IPv4, Proto: TCP, Action: Accept},
|
|
{Family: IPv4, Proto: UDP, Action: Accept},
|
|
}))
|
|
require.False(t, portless.CoveredBy([]*Rule{
|
|
{Family: IPv4, Proto: ProtocolAny, Action: Accept},
|
|
}), "ProtocolAny is not a tcp+udp rule")
|
|
require.False(t, (&Rule{Family: IPv4, Proto: ProtocolAny, Action: Accept}).CoveredBy([]*Rule{portless}),
|
|
"a TCPUDP rule does not cover every IP protocol")
|
|
|
|
// A cross-family pair leaves half of each family uncovered.
|
|
require.False(t, ported.CoveredBy([]*Rule{
|
|
{Family: IPv4, Port: 53, Proto: TCP, Action: Accept},
|
|
{Family: IPv6, Port: 53, Proto: UDP, Action: Accept},
|
|
}), "a cross-family tcp/udp pair must not be read as coverage")
|
|
|
|
// A same-protocol duplicate is not cross-transport coverage.
|
|
require.False(t, ported.CoveredBy([]*Rule{
|
|
{Family: IPv4, Port: 53, Proto: TCP, Action: Accept},
|
|
{Family: IPv4, Port: 53, Proto: TCP, Action: Accept},
|
|
}))
|
|
|
|
// Rules differing in another field never cover.
|
|
require.False(t, ported.CoveredBy([]*Rule{
|
|
{Family: IPv4, Port: 53, Proto: TCP, Action: Accept},
|
|
{Family: IPv4, Port: 54, Proto: UDP, Action: Accept},
|
|
}))
|
|
|
|
// One native TCPUDP row covers both transports on its own, and covers each half.
|
|
require.True(t, ported.CoveredBy([]*Rule{ported}))
|
|
require.True(t, ported.Covers(&Rule{Family: IPv4, Port: 53, Proto: TCP, Action: Accept}))
|
|
require.False(t, (&Rule{Family: IPv4, Port: 53, Proto: TCP, Action: Accept}).Covers(ported),
|
|
"coverage is asymmetric")
|
|
}
|
|
|
|
// TestExpandProtocols covers the write-side fan-out a backend with no both-transports
|
|
// form applies before marshalling.
|
|
func TestExpandProtocols(t *testing.T) {
|
|
rows := expandProtocols(&Rule{Port: 53, Proto: TCPUDP, Action: Accept})
|
|
require.Len(t, rows, 2)
|
|
require.Equal(t, TCP, rows[0].Proto)
|
|
require.Equal(t, UDP, rows[1].Proto)
|
|
|
|
// A concrete transport, and the genuinely-every-protocol match, pass through.
|
|
require.Len(t, expandProtocols(&Rule{Port: 53, Proto: TCP}), 1)
|
|
require.Len(t, expandProtocols(&Rule{Proto: ProtocolAny}), 1)
|
|
|
|
// The caller's rule is untouched.
|
|
src := &Rule{Port: 53, Proto: TCPUDP}
|
|
_ = expandProtocols(src)
|
|
require.Equal(t, TCPUDP, src.Proto)
|
|
|
|
// The rows a TCPUDP rule expands to cover it exactly, so a backend that fans it
|
|
// out reports a set Sync still recognizes as the rule the caller asked for.
|
|
want := &Rule{Family: IPv4, Port: 53, Proto: TCPUDP, Action: Accept}
|
|
require.True(t, want.CoveredBy(expandProtocols(want)))
|
|
}
|
|
|
|
// TestSplitDualRowProtocol covers removing one transport of a stored TCPUDP row:
|
|
// the row goes and the untargeted transport is re-added.
|
|
func TestSplitDualRowProtocol(t *testing.T) {
|
|
row := &Rule{Family: IPv4, Port: 53, Proto: TCPUDP, Action: Accept}
|
|
|
|
surv := splitDualRowProtocol(row, &Rule{Family: IPv4, Port: 53, Proto: TCP, Action: Accept})
|
|
require.NotNil(t, surv)
|
|
require.Equal(t, UDP, surv.Proto, "removing tcp from a tcpudp row leaves udp")
|
|
|
|
surv = splitDualRowProtocol(row, &Rule{Family: IPv4, Port: 53, Proto: UDP, Action: Accept})
|
|
require.NotNil(t, surv)
|
|
require.Equal(t, TCP, surv.Proto)
|
|
|
|
// A TCPUDP target clears the whole row, so there is nothing to re-add.
|
|
require.Nil(t, splitDualRowProtocol(row, &Rule{Proto: TCPUDP}))
|
|
// A concrete row is not a merged row.
|
|
require.Nil(t, splitDualRowProtocol(&Rule{Proto: TCP}, &Rule{Proto: TCP}))
|
|
}
|
|
|
|
// TestCoversProtocol pins the asymmetric add form and the symmetric remove form.
|
|
func TestCoversProtocol(t *testing.T) {
|
|
// Add/dedup: a stored TCPUDP row makes a concrete add redundant, but not vice
|
|
// versa — a stored tcp row leaves udp unprotected.
|
|
require.True(t, coversProtocol(TCPUDP, TCP))
|
|
require.True(t, coversProtocol(TCPUDP, UDP))
|
|
require.False(t, coversProtocol(TCP, TCPUDP))
|
|
require.False(t, coversProtocol(TCP, UDP))
|
|
require.True(t, coversProtocol(TCP, TCP))
|
|
// ProtocolAny is not a merged value and covers only itself.
|
|
require.False(t, coversProtocol(ProtocolAny, TCP))
|
|
require.True(t, coversProtocol(ProtocolAny, ProtocolAny))
|
|
|
|
// Remove/move: either side may be the merged value.
|
|
require.True(t, coversProtocolRemoval(TCPUDP, TCP))
|
|
require.True(t, coversProtocolRemoval(TCP, TCPUDP))
|
|
require.False(t, coversProtocolRemoval(TCP, UDP))
|
|
require.False(t, coversProtocolRemoval(ProtocolAny, TCP))
|
|
}
|
|
|
|
// TestEqualForRemovalProtocolCoverage: a TCPUDP target must match each concrete row
|
|
// it covers (so one RemoveRule clears both), and a concrete target must match a
|
|
// stored TCPUDP row (so the caller can split it).
|
|
func TestEqualForRemovalProtocolCoverage(t *testing.T) {
|
|
tcpRow := &Rule{Family: IPv4, Port: 53, Proto: TCP, Action: Accept}
|
|
udpRow := &Rule{Family: IPv4, Port: 53, Proto: UDP, Action: Accept}
|
|
bothRow := &Rule{Family: IPv4, Port: 53, Proto: TCPUDP, Action: Accept}
|
|
|
|
require.True(t, tcpRow.EqualForRemoval(bothRow, true))
|
|
require.True(t, udpRow.EqualForRemoval(bothRow, true))
|
|
require.True(t, bothRow.EqualForRemoval(tcpRow, true))
|
|
require.False(t, tcpRow.EqualForRemoval(udpRow, true), "acting on tcp must never disturb udp")
|
|
|
|
// Dedup: a stored TCPUDP row absorbs a concrete add; a stored tcp row does not
|
|
// absorb a TCPUDP add (that would leave udp unprotected).
|
|
require.True(t, bothRow.EqualForDedup(tcpRow, true))
|
|
require.False(t, tcpRow.EqualForDedup(bothRow, true))
|
|
}
|
|
|
|
// A rule spanning all three axes has eight cells, so a backend that can store none of
|
|
// them reports it as eight physical rows. Those rows cover it exactly, and dropping
|
|
// any one of them breaks the coverage — which is what keeps Sync from re-adding a
|
|
// rule that is already fully installed, and from calling a half-installed rule done.
|
|
func TestAllThreeAxesCoverage(t *testing.T) {
|
|
// No address, so nothing pins the family: the rule genuinely spans both.
|
|
var rows []*Rule
|
|
for _, fam := range []Family{IPv4, IPv6} {
|
|
for _, proto := range []Protocol{TCP, UDP} {
|
|
rows = append(rows,
|
|
&Rule{Family: fam, Proto: proto, Port: 53, Direction: DirInput, Action: Accept},
|
|
&Rule{Family: fam, Proto: proto, SourcePort: 53, Direction: DirOutput, Action: Accept},
|
|
)
|
|
}
|
|
}
|
|
require.Len(t, rows, 8)
|
|
|
|
want := &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Direction: DirAny, Action: Accept}
|
|
require.Len(t, want.cells(true), 8, "the rule spans eight concrete cells")
|
|
require.True(t, want.CoveredBy(rows), "the eight rows cover the rule")
|
|
|
|
for i := range rows {
|
|
missing := append(append([]*Rule{}, rows[:i]...), rows[i+1:]...)
|
|
require.False(t, want.CoveredBy(missing), "dropping row %d must break coverage", i)
|
|
}
|
|
|
|
// An address pins the family, so the same rule then spans only four cells: the
|
|
// direction swap moves it to the destination, and both halves stay IPv4.
|
|
addressed := &Rule{Proto: TCPUDP, Source: "192.0.2.1", Port: 53, Direction: DirAny, Action: Accept}
|
|
require.Len(t, addressed.cells(true), 4, "an IPv4 source pins the family axis")
|
|
}
|
|
|
|
// TestSyncCanonicalizesProtocol: a caller that lists tcp and udp separately must not
|
|
// churn against a backend that stores the pair as one native TCPUDP rule.
|
|
func TestSyncCanonicalizesProtocol(t *testing.T) {
|
|
m := &syncStateMock{existing: []*Rule{
|
|
{Family: IPv4, Port: 53, Proto: TCPUDP, Action: Accept},
|
|
}}
|
|
desired := []*Rule{
|
|
{Family: IPv4, Port: 53, Proto: TCP, Action: Accept},
|
|
{Family: IPv4, Port: 53, Proto: UDP, Action: Accept},
|
|
}
|
|
added, removed, err := Sync(context.Background(), m, "", desired)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 0, added, "the merged rule already covers both transports")
|
|
require.Equal(t, 0, removed, "Sync must not remove-and-re-add a merged rule")
|
|
require.Equal(t, TCP, desired[0].Proto, "Sync must not mutate the caller's desired rules")
|
|
}
|
|
|
|
// TestProtocolTCPUDPSerialization: TCPUDP must survive the Backup/Restore JSON round
|
|
// trip under its own stable name. Reading it back as ProtocolAny would silently widen
|
|
// a restored rule from two transports to every IP protocol.
|
|
func TestProtocolTCPUDPSerialization(t *testing.T) {
|
|
require.Equal(t, "tcpudp", TCPUDP.String())
|
|
require.Equal(t, TCPUDP, GetProtocol("tcpudp"))
|
|
require.Equal(t, TCPUDP, GetProtocol("TCPUDP"), "protocol names are case-insensitive")
|
|
|
|
data, err := json.Marshal(TCPUDP)
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, `"tcpudp"`, string(data))
|
|
|
|
var back Protocol
|
|
require.NoError(t, json.Unmarshal(data, &back))
|
|
require.Equal(t, TCPUDP, back)
|
|
|
|
// TCPUDP carries ports; ProtocolAny does not, so a port on it stays rejected.
|
|
require.True(t, TCPUDP.HasPorts())
|
|
require.False(t, ProtocolAny.HasPorts())
|
|
require.False(t, (&Rule{Proto: TCPUDP, Port: 80}).PortNeedsConcreteProtocol())
|
|
require.True(t, (&Rule{Proto: ProtocolAny, Port: 80}).PortNeedsConcreteProtocol())
|
|
|
|
// A row-level marshaller must never see the merged value.
|
|
require.Error(t, (&Rule{Proto: TCPUDP}).CheckExpandedProtocol())
|
|
require.NoError(t, (&Rule{Proto: TCP}).CheckExpandedProtocol())
|
|
}
|
|
|
|
// TestRuleCovers pins the exported coverage relation: a merged rule contains its
|
|
// concrete halves on every axis, never the reverse, and ProtocolAny is not a merged
|
|
// value.
|
|
func TestRuleCovers(t *testing.T) {
|
|
merged := &Rule{Family: FamilyAny, Proto: TCPUDP, Direction: DirAny, Port: 53, Action: Accept}
|
|
cell := &Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 53, Action: Accept}
|
|
|
|
require.True(t, merged.Covers(cell), "a rule merged on every axis covers each of its cells")
|
|
require.False(t, cell.Covers(merged), "coverage is asymmetric: a concrete rule cannot cover a merged one")
|
|
require.True(t, cell.Covers(cell), "a rule covers itself")
|
|
|
|
// Each axis independently.
|
|
require.True(t, (&Rule{Family: FamilyAny, Proto: TCP, Port: 53, Action: Accept}).
|
|
Covers(&Rule{Family: IPv6, Proto: TCP, Port: 53, Action: Accept}))
|
|
require.True(t, (&Rule{Proto: TCPUDP, Port: 53, Action: Accept}).
|
|
Covers(&Rule{Proto: UDP, Port: 53, Action: Accept}))
|
|
require.True(t, (&Rule{Proto: TCP, Direction: DirAny, Port: 53, Action: Accept}).
|
|
Covers(&Rule{Proto: TCP, Direction: DirOutput, SourcePort: 53, Action: Accept}),
|
|
"a DirAny rule covers its role-swapped output half")
|
|
|
|
// Siblings never cover each other.
|
|
require.False(t, (&Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}).
|
|
Covers(&Rule{Family: IPv6, Proto: TCP, Port: 53, Action: Accept}))
|
|
require.False(t, (&Rule{Proto: TCP, Port: 53, Action: Accept}).
|
|
Covers(&Rule{Proto: UDP, Port: 53, Action: Accept}))
|
|
|
|
// ProtocolAny matches every IP protocol; it is not the merged tcp/udp value and
|
|
// so covers neither transport.
|
|
require.False(t, (&Rule{Proto: ProtocolAny, Action: Accept}).Covers(&Rule{Proto: TCP, Action: Accept}))
|
|
require.True(t, (&Rule{Proto: ProtocolAny, Action: Accept}).Covers(&Rule{Proto: ProtocolAny, Action: Accept}))
|
|
|
|
// An ordinary field must still match exactly.
|
|
require.False(t, merged.Covers(&Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 53, Action: Drop}),
|
|
"a different action is a different rule")
|
|
require.False(t, merged.Covers(&Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 54, Action: Accept}))
|
|
}
|
|
|
|
// TestCoveredByAcrossSeveralRules is why CoveredBy exists: a merged rule's coverage
|
|
// may be spread across several stored rules that no single rule Covers.
|
|
func TestCoveredByAcrossSeveralRules(t *testing.T) {
|
|
want := &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}
|
|
|
|
// The four concrete cells, held separately. No single one covers want.
|
|
four := []*Rule{
|
|
{Family: IPv4, Proto: TCP, Port: 53, Action: Accept},
|
|
{Family: IPv6, Proto: TCP, Port: 53, Action: Accept},
|
|
{Family: IPv4, Proto: UDP, Port: 53, Action: Accept},
|
|
{Family: IPv6, Proto: UDP, Port: 53, Action: Accept},
|
|
}
|
|
for _, r := range four {
|
|
require.False(t, r.Covers(want))
|
|
}
|
|
require.True(t, want.CoveredBy(four), "the set covers want even though no single rule does")
|
|
|
|
// Drop any one cell and the set no longer covers want.
|
|
for i := range four {
|
|
short := append(append([]*Rule{}, four[:i]...), four[i+1:]...)
|
|
require.False(t, want.CoveredBy(short), "removing cell %d must leave want uncovered", i)
|
|
}
|
|
|
|
// One fully merged rule covers it, as does a mixed set.
|
|
require.True(t, want.CoveredBy([]*Rule{{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}}))
|
|
require.True(t, want.CoveredBy([]*Rule{
|
|
{Family: FamilyAny, Proto: TCP, Port: 53, Action: Accept},
|
|
{Family: IPv4, Proto: UDP, Port: 53, Action: Accept},
|
|
{Family: IPv6, Proto: UDP, Port: 53, Action: Accept},
|
|
}))
|
|
|
|
// A concrete want is covered by a merged rule.
|
|
require.True(t, (&Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept}).
|
|
CoveredBy([]*Rule{{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}}))
|
|
|
|
// Nothing covers anything in an empty set; want is left untouched.
|
|
require.False(t, want.CoveredBy(nil))
|
|
require.Equal(t, TCPUDP, want.Proto)
|
|
require.Equal(t, FamilyAny, want.Family)
|
|
}
|
|
|
|
// TestCoveredByDirection: a DirAny rule needs both directions present, and the
|
|
// outbound half is matched in its role-swapped frame.
|
|
func TestCoveredByDirection(t *testing.T) {
|
|
want := &Rule{Proto: TCP, Direction: DirAny, Source: "192.0.2.1", Port: 22, Action: Accept}
|
|
|
|
in := &Rule{Proto: TCP, Direction: DirInput, Source: "192.0.2.1", Port: 22, Action: Accept}
|
|
out := &Rule{Proto: TCP, Direction: DirOutput, Destination: "192.0.2.1", SourcePort: 22, Action: Accept}
|
|
|
|
require.False(t, want.CoveredBy([]*Rule{in}), "the input half alone does not cover a both-directions rule")
|
|
require.True(t, want.CoveredBy([]*Rule{in, out}), "the swapped output twin completes the coverage")
|
|
|
|
// A forward rule has no opposite direction and stands alone.
|
|
fwd := &Rule{Proto: TCP, Direction: DirForward, Port: 22, Action: Accept}
|
|
require.True(t, fwd.CoveredBy([]*Rule{fwd}))
|
|
require.False(t, fwd.CoveredBy([]*Rule{in, out}))
|
|
}
|
|
|
|
// TestNATCoveredBy mirrors Rule.CoveredBy over the single axis NAT merges on. A Redirect
|
|
// carries no translation address, so its family is genuinely FamilyAny — a DNAT's
|
|
// ToAddress would pin the family through impliedFamily.
|
|
func TestNATCoveredBy(t *testing.T) {
|
|
want := &NATRule{Kind: Redirect, Family: FamilyAny, Proto: TCP, Port: 80, ToPort: 8080}
|
|
v4 := &NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}
|
|
v6 := &NATRule{Kind: Redirect, Family: IPv6, Proto: TCP, Port: 80, ToPort: 8080}
|
|
|
|
require.True(t, want.Covers(v4))
|
|
require.False(t, v4.Covers(want))
|
|
require.False(t, v4.Covers(v6))
|
|
|
|
require.False(t, want.CoveredBy([]*NATRule{v4}))
|
|
require.True(t, want.CoveredBy([]*NATRule{v4, v6}))
|
|
require.True(t, v6.CoveredBy([]*NATRule{want}))
|
|
|
|
// A DNAT's translation address pins the family, so a FamilyAny DNAT to an IPv4
|
|
// target is already an IPv4 rule and one concrete rule covers it.
|
|
dnatAny := &NATRule{Kind: DNAT, Family: FamilyAny, Proto: TCP, Port: 80, ToAddress: "192.0.2.9"}
|
|
dnatV4 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "192.0.2.9"}
|
|
require.True(t, dnatAny.CoveredBy([]*NATRule{dnatV4}),
|
|
"the translation address already pins this rule to IPv4")
|
|
}
|