- New Capabilities: PortPair, Negation, RejectAction, FamilyWithoutAddress, DenyActionFromConfig, advertised per backend. - coversDirection isolates DirForward even when output is unowned; add splitNATDualRow so a concrete-family removal re-adds the opposite family's NAT translation. - Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the "invalid" sentinel as input while JSON round-trips it. - Sync counts additions on mid-batch failure and uses RuleBatcher. - NewManager runs a probe loop joining each backend's reason for diagnosability; services.go drops "generated" from enabled, handles it on enable, clears start-limit-hit on restart, and matches rc.local by token. - nftables: per-source connection limits (meter set), quoted-token parsing preserving log-prefix spacing, digit-led prefix sanitizing. - apf/csf: deny-action-from-config with cached STOP settings, port lists and inexpressible shapes routed through the pre-hook, confKeyApplies guard against a missing config line. - atomic config writes fsync before rename and resolve symlinks; readConfValue is last-assignment-wins; runCommand preserves the exit code through the wrapped error. - Move coreos/go-systemd to the maintained v22 module directly.
1733 lines
81 KiB
Go
1733 lines
81 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")
|
|
}
|
|
|
|
// coversDirection with outputHonored=false collapses input/output but must not
|
|
// let an input row cover a DirForward target: forward is its own chain even on a
|
|
// backend with no output concept.
|
|
func TestCoversDirectionForwardIsolated(t *testing.T) {
|
|
require.True(t, coversDirection(DirInput, DirOutput, false))
|
|
require.True(t, coversDirection(DirOutput, DirInput, false))
|
|
require.False(t, coversDirection(DirInput, DirForward, false))
|
|
require.False(t, coversDirection(DirForward, DirInput, false))
|
|
require.True(t, coversDirection(DirForward, DirForward, false))
|
|
require.False(t, coversDirectionRemoval(DirInput, DirForward, false))
|
|
require.True(t, coversDirectionRemoval(DirForward, DirForward, false))
|
|
}
|
|
|
|
// The ip6tables/ufw ICMPv6 type spellings resolve alongside the nftables names.
|
|
func TestICMPv6NameAliases(t *testing.T) {
|
|
cases := map[string]uint8{
|
|
"router-solicitation": 133,
|
|
"router-advertisement": 134,
|
|
"neighbor-solicitation": 135,
|
|
"neighbour-solicitation": 135,
|
|
"neighbor-advertisement": 136,
|
|
"neighbour-advertisement": 136,
|
|
"redirect": 137,
|
|
"nd-redirect": 137,
|
|
}
|
|
for name, want := range cases {
|
|
n, ok := parseICMPTypeFamily(name, true)
|
|
require.True(t, ok, "name %q must resolve", name)
|
|
require.Equal(t, want, n, "name %q", name)
|
|
}
|
|
}
|
|
|
|
// ParseNATKind rejects the "invalid" sentinel as caller input while the JSON
|
|
// round-trip still accepts it, mirroring Action.
|
|
func TestParseNATKindRejectsSentinel(t *testing.T) {
|
|
_, err := ParseNATKind("invalid")
|
|
require.Error(t, err)
|
|
var k NATKind
|
|
require.NoError(t, k.UnmarshalJSON([]byte(`"invalid"`)))
|
|
require.Equal(t, NATInvalid, k)
|
|
}
|