go-firewall/pf_test.go
James Coleman a036c8e6e9 Add TCPUDP protocol, coverage relation, and drop read-side merging
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.
2026-07-09 17:52:19 -05:00

709 lines
32 KiB
Go

//go:build darwin || freebsd
package firewall
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestPFMissingTableErr verifies the sentinel that makes address-set removal
// idempotent. pfctl prints "Table does not exist" for a missing table, so the
// removal helpers must recognize that exact wording (an earlier "not found" guard
// never matched, so removing an absent set errored instead of no-op succeeding).
func TestPFMissingTableErr(t *testing.T) {
fw := new(PF)
require.True(t, fw.isMissingTableErr(fmt.Errorf("pfctl: Table does not exist.")),
"pfctl's actual missing-table message must be recognized")
require.False(t, fw.isMissingTableErr(fmt.Errorf("pfctl: not found")),
"the wrong sentinel must not match")
require.False(t, fw.isMissingTableErr(nil))
require.False(t, fw.isMissingTableErr(fmt.Errorf("pfctl: permission denied")),
"a real failure must not be treated as a missing table")
}
// TestPFICMP6TypeNameParse verifies that an icmp6-type printed by name resolves
// through the ICMPv6 table: pfctl reuses ICMPv4 spellings (e.g. echoreq) for
// different ICMPv6 numbers, so the ICMPv4 table would decode it wrongly.
func TestPFICMP6TypeNameParse(t *testing.T) {
f := &PF{anchor: "go_firewall"}
// echoreq is ICMPv6 type 128 (it is 8 under ICMPv4).
line, err := f.MarshalRule(&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept})
require.NoError(t, err)
named := strings.Replace(line, "icmp6-type 128", "icmp6-type echoreq", 1)
require.NotEqual(t, line, named, "marshaled rule should contain the numeric icmp6 type")
got, err := f.UnmarshalRule(named)
require.NoError(t, err)
require.NotNil(t, got.ICMPType)
require.Equal(t, uint8(128), *got.ICMPType, "echoreq must resolve to ICMPv6 type 128, not the ICMPv4 8")
// An ICMPv4 rule must still resolve echoreq to 8.
line4, err := f.MarshalRule(&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept})
require.NoError(t, err)
named4 := strings.Replace(line4, "icmp-type 8", "icmp-type echoreq", 1)
got4, err := f.UnmarshalRule(named4)
require.NoError(t, err)
require.NotNil(t, got4.ICMPType)
require.Equal(t, uint8(8), *got4.ICMPType)
}
// TestPFTranslationBoundary verifies the nat/rdr anchors are inserted before the
// first filtering statement and AFTER any queueing (altq/queue) section — pf.conf
// sections are strictly ordered options → normalization → queueing → translation
// → filtering, so treating a queueing keyword as the boundary would splice the
// anchors ahead of it and produce a ruleset pfctl -f rejects.
func TestPFTranslationBoundary(t *testing.T) {
fw := new(PF)
// altq/queue precede the first pass/block; the boundary must be the pass line.
conf := []string{
"set skip on lo",
"scrub in all",
`altq on em0 bandwidth 100Mb hfsc queue { q_def }`,
`queue q_def bandwidth 100% hfsc(default)`,
"pass out all",
"block in all",
}
require.Equal(t, 4, fw.translationBoundary(conf),
"anchors must go after the queueing section, at the first pass/block")
// No filtering statements: append at the end.
require.Equal(t, 2, fw.translationBoundary([]string{"set skip on lo", "scrub in all"}))
// antispoof and anchor also open the filtering section.
require.Equal(t, 0, fw.translationBoundary([]string{"antispoof for em0"}))
require.Equal(t, 1, fw.translationBoundary([]string{"scrub in all", `anchor "foo"`}))
}
// TestPFHighICMPTypeNames verifies the high ICMPv4 type names pfctl prints (31-40)
// round-trip: MarshalRule emits the numeric type, pfctl re-spells it by name on
// -sr, and UnmarshalRule must resolve that name back to the number.
func TestPFHighICMPTypeNames(t *testing.T) {
f := &PF{anchor: "go_firewall"}
for name, num := range map[string]uint8{"photuris": 40, "skip": 39, "mobregreq": 35, "ipv6-where": 33} {
line, err := f.MarshalRule(&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr(num), Action: Accept})
require.NoError(t, err)
named := strings.Replace(line, "icmp-type "+strconv.Itoa(int(num)), "icmp-type "+name, 1)
require.NotEqual(t, line, named, "expected numeric icmp-type in %q", line)
got, err := f.UnmarshalRule(named)
require.NoError(t, err, "pfctl name %q (type %d) must parse", name, num)
require.NotNil(t, got.ICMPType)
require.Equal(t, num, *got.ICMPType, "%s must resolve to %d", name, num)
}
}
// TestPFProtocolAndComment round-trips the added protocols and a rule comment
// (a pf label) through the pf rule encoder.
func TestPFProtocolAndComment(t *testing.T) {
f := &PF{anchor: "go_firewall"}
cases := []*Rule{
{Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept},
{Family: IPv4, Proto: GRE, Action: Accept},
{Family: IPv4, Proto: ESP, Action: Accept},
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"},
}
for _, orig := range cases {
line, err := f.MarshalRule(orig)
require.NoError(t, err)
got, err := f.UnmarshalRule(line)
require.NoError(t, err, "line %q", line)
require.True(t, got.EqualBase(orig, true), "line %q: want %+v got %+v", line, orig, got)
require.Equal(t, orig.Comment, got.Comment, "line %q comment", line)
}
}
// TestPFAnchorPreservesUnmodeled verifies parseAnchorRules keeps a rule line it
// cannot model as an opaque row (nil rule, raw text preserved) instead of dropping
// it, so a read-modify-write rewrite of our anchor does not silently delete a
// foreign rule loaded into it. The rules slice stays 1:1 with raw so the physical
// row edits (insert/move/remove) never misalign.
func TestPFAnchorPreservesUnmodeled(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
// The middle line uses a pf port operator (port > 1023) this backend does not
// model, so it cannot become a Rule; the two surrounding lines are modeled.
out := []string{
"pass in quick inet proto tcp from any to any port = 22 keep state",
"pass in quick inet proto tcp from any to any port > 1023 keep state",
"pass out quick inet proto udp from any to any port = 53 keep state",
}
rules, raw := fw.parseAnchorRules(out)
require.Len(t, rules, 3, "every physical row needs a slot, the opaque one included")
require.Len(t, raw, 3, "raw must stay 1:1 with rules")
require.NotNil(t, rules[0])
require.Nil(t, rules[1], "the unmodeled line must be an opaque (nil) row")
require.NotNil(t, rules[2])
require.Equal(t, out[1], raw[1], "the unmodeled line's text must be preserved verbatim")
}
// TestPFReorderRowsKeepsOpaque verifies the move/remove row rebuild keeps an opaque
// (nil) row in place and maps the target position to the correct physical index past
// it, so relocating a modeled rule never drops or displaces a foreign line sharing
// our anchor.
func TestPFReorderRowsKeepsOpaque(t *testing.T) {
fw := new(PF)
ruleA := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept}
ruleB := &Rule{Family: IPv4, Port: 53, Proto: UDP, Action: Accept}
rules := []*Rule{ruleA, nil, ruleB}
raw := []string{"lineA", "opaque", "lineB"}
// Move ruleB (a distinct rule) to the front.
out, moved := fw.reorderRows(rules, raw, ruleB, 1)
require.True(t, moved)
require.Equal(t, []string{"lineB", "lineA", "opaque"}, out,
"the opaque line must be kept; only the modeled rule relocates")
}
func TestPFRules(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
// Marshal a representative rule and confirm the pf rule line.
line, err := fw.MarshalRule(&Rule{
Family: IPv4,
Source: "192.168.0.0/24",
Port: 23,
Proto: UDP,
Action: Accept,
})
require.NoError(t, err)
require.Equal(t, "pass in quick inet proto udp from 192.168.0.0/24 to any port 23", line,
"unexpected rule line")
// The normalized form emitted by `pfctl -sr` must parse back to an
// equivalent rule.
rule, err := fw.UnmarshalRule("pass in quick inet proto udp from 192.168.0.0/24 to any port = 23 keep state")
require.NoError(t, err)
want := &Rule{Family: IPv4, Source: "192.168.0.0/24", Port: 23, Proto: UDP, Action: Accept}
require.True(t, rule.Equal(want, true), "parsed rule does not match: got %+v", rule)
// Round-trip the rules we typically set across directions, families and
// actions.
rules := []*Rule{
{Family: IPv4, Port: 4789, Proto: UDP, Action: Accept},
{Direction: DirOutput, Family: IPv6, Port: 4789, Proto: UDP, Action: Accept},
{Family: IPv4, Source: "67.227.233.116", Port: 4789, Proto: TCP, Action: Accept},
{Direction: DirOutput, Family: IPv4, Destination: "67.227.233.116", Port: 4791, Proto: TCP, Action: Reject},
{Family: IPv6, Source: "!2001:db8::1", Action: Drop},
// A non-address Source/Destination names a pf table, referenced as <name>.
{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop},
{Direction: DirOutput, Family: IPv4, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept},
}
for _, r := range rules {
line, err := fw.MarshalRule(r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed, err := fw.UnmarshalRule(line)
require.NoError(t, err, "failed to parse %q", line)
require.True(t, parsed.Equal(r, true),
"round-trip mismatch: input %+v, line %q, output %+v", *r, line, parsed)
}
// A non-address Source is emitted as a pf table reference in angle brackets.
setLine, err := fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop})
require.NoError(t, err)
require.Contains(t, setLine, "from <blocklist>")
// Invalid lines must be rejected.
invalidRules := []string{
"pass in quick inet proto foo from any to any",
"frobnicate in quick from any to any",
"pass sideways quick from any to any",
}
for _, line := range invalidRules {
_, err := fw.UnmarshalRule(line)
require.Error(t, err, "line parsed when it should be invalid: %s", line)
}
// A port without a concrete protocol cannot be expressed in pf.
_, err = fw.MarshalRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept})
require.Error(t, err, "expected error marshalling a port with no protocol")
// A single source port and a contiguous source-port range round-trip and are
// accepted; a discrete source-port list does not round-trip (pfctl expands it),
// so it is rejected rather than emitted.
_, err = fw.MarshalRule(&Rule{Proto: TCP, SourcePort: 1024, Action: Accept})
require.NoError(t, err, "a single source port is valid")
_, err = fw.MarshalRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 2048}}, Action: Accept})
require.NoError(t, err, "a source-port range is valid")
_, err = fw.MarshalRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept})
require.Error(t, err, "a discrete source-port list must be rejected")
}
// pf exposes per-rule counters through `pfctl -vsr`, which prints a
// `[ Evaluations: N Packets: N Bytes: N States: N ]` continuation line under
// each rule (and may prefix rules with a @N number in a verbose listing).
// parseAnchorRules attaches those counters to the preceding rule.
func TestPFRuleCounters(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
out := []string{
"@0 pass in quick proto tcp from any to any port = 22",
" [ Evaluations: 100 Packets: 40 Bytes: 2400 States: 2 ]",
" [ Inserted: uid 0 pid 1 State Creations: 2 ]",
"pass in quick proto udp from any to any port = 53",
" [ Evaluations: 5 Packets: 5 Bytes: 300 States: 0 ]",
}
rules, raw := fw.parseAnchorRules(out)
require.Len(t, rules, 2, "expected two rules parsed")
require.Len(t, raw, 2, "raw must exclude the continuation lines")
// The @N prefix is stripped so the raw text stays loadable by pfctl -f.
require.NotContains(t, raw[0], "@0", "the rule-number prefix must be stripped: %q", raw[0])
require.EqualValues(t, 40, rules[0].Packets)
require.EqualValues(t, 2400, rules[0].Bytes)
require.EqualValues(t, 22, rules[0].Port)
require.EqualValues(t, 5, rules[1].Packets)
require.EqualValues(t, 300, rules[1].Bytes)
// The counter parser only fires on a line that carries both counters.
p, b, ok := fw.parseRuleCounters("[ Evaluations: 1 Packets: 7 Bytes: 500 States: 0 ]")
require.True(t, ok)
require.EqualValues(t, 7, p)
require.EqualValues(t, 500, b)
_, _, ok = fw.parseRuleCounters("[ Inserted: uid 0 pid 1 State Creations: 2 ]")
require.False(t, ok, "a non-counter continuation line must not report counters")
}
func TestPFFeatureRules(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
// Confirm representative encodings.
cases := []struct {
rule *Rule
want string
}{
{&Rule{Proto: ICMP, Action: Accept}, "pass in quick inet proto icmp from any to any"},
{&Rule{Proto: ICMPv6, Action: Accept}, "pass in quick inet6 proto icmp6 from any to any"},
{&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, "pass in quick inet proto icmp from any to any icmp-type 8"},
{&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept}, "pass in quick inet6 proto icmp6 from any to any icmp6-type 135"},
{&Rule{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, "pass in quick proto udp from any to any port 1000:2000"},
{&Rule{InInterface: "em0", Proto: TCP, Port: 22, Action: Accept}, "pass in quick on em0 proto tcp from any to any port 22"},
{&Rule{Direction: DirOutput, OutInterface: "em1", Action: Drop}, "block drop out quick on em1 from any to any"},
}
for _, c := range cases {
got, err := fw.MarshalRule(c.rule)
require.NoError(t, err, "failed to marshal %+v", *c.rule)
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
}
// Round-trip every new-feature rule shape.
rules := []*Rule{
{Proto: ICMP, Action: Accept},
{Proto: ICMPv6, Action: Drop},
{Family: IPv6, Proto: ICMPv6, Action: Accept},
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept},
{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
{InInterface: "em0", Proto: TCP, Port: 22, Action: Accept},
{Direction: DirOutput, OutInterface: "em1", Proto: UDP, Port: 53, Action: Accept},
}
for _, r := range rules {
line, err := fw.MarshalRule(r)
require.NoError(t, err, "failed to marshal %+v", *r)
parsed, err := fw.UnmarshalRule(line)
require.NoError(t, err, "failed to parse %q", line)
require.True(t, parsed.Equal(r, true),
"round-trip mismatch: input %+v, line %q, output %+v", *r, line, parsed)
}
// pf cannot express a discrete destination-port list (PortList is false):
// pfctl expands `port { 80 443 }` into one rule per port on read, so it is
// rejected rather than emitted as a rule that reads back as several.
_, err := fw.MarshalRule(&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept})
require.ErrorIs(t, err, ErrUnsupported, "a destination-port list must be rejected under pf")
// pf cannot express a connection-state match in this model.
_, err = fw.MarshalRule(&Rule{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept})
require.Error(t, err, "expected error marshalling a state match under pf")
// Interface/direction mismatches must be rejected.
_, err = fw.MarshalRule(&Rule{Direction: DirOutput, InInterface: "em0", Action: Accept})
require.Error(t, err, "expected error matching an input interface on an output rule")
_, err = fw.MarshalRule(&Rule{OutInterface: "em0", Action: Accept})
require.Error(t, err, "expected error matching an output interface on an input rule")
// pf has no distinct forward chain, so a forward rule is rejected with the
// ErrUnsupportedForward sentinel.
_, err = fw.MarshalRule(&Rule{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept})
require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected")
}
func TestPFLogLimitRoundTrip(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
cases := []*Rule{
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Log: true},
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept,
ConnLimit: &ConnLimit{Count: 100, PerSource: true},
RateLimit: &RateLimit{Rate: 15, Unit: PerSecond}},
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}},
}
for _, orig := range cases {
line, err := fw.MarshalRule(orig)
require.NoError(t, err)
got, err := fw.UnmarshalRule(line)
require.NoError(t, err, "line %q", line)
require.True(t, got.EqualBase(orig, true), "line %q: want %+v got %+v", line, orig, got)
}
// pf has no log prefix, and limits require an accept rule.
_, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "x"})
require.Error(t, err, "expected pf to reject a log prefix")
_, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}})
require.Error(t, err, "expected pf to reject a limit on a non-accept rule")
_, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, ConnLimit: &ConnLimit{Count: 5, PerSource: false}})
require.Error(t, err, "expected pf to reject a non-per-source connection limit")
}
func TestPFNATRoundTrip(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
cases := []*NATRule{
{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Interface: "em0"},
{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4", Interface: "em0"},
{Kind: Masquerade, Family: IPv4, Interface: "em0"},
}
for _, orig := range cases {
line, err := fw.MarshalNATRule(orig)
require.NoError(t, err)
got, err := fw.UnmarshalNATRule(line)
require.NoError(t, err, "line %q", line)
require.True(t, got.EqualBase(orig), "line %q: want %+v got %+v", line, orig, got)
}
// pfctl prints a well-known translation port by its /etc/services name, e.g. a
// DNAT to port 80 reads back as `-> 10.0.0.5 port http`. The target port must
// resolve through the service-name lookup like a match port; a number-only
// parse fails and anchorNATRules silently drops the rule from the snapshot.
named, err := fw.UnmarshalNATRule("rdr on em0 inet proto tcp from any to any port www -> 10.0.0.5 port http")
require.NoError(t, err, "a named nat target port must parse")
require.Equal(t, uint16(80), named.ToPort, "named target port http must resolve to 80")
// pf has no portless redirect and masquerade needs an interface.
_, err = fw.MarshalNATRule(&NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080})
require.Error(t, err, "expected pf to reject a redirect")
_, err = fw.MarshalNATRule(&NATRule{Kind: Masquerade, Family: IPv4})
require.Error(t, err, "expected pf masquerade to require an interface")
}
// pf's max-src-conn-rate has no burst term, so a rate limit carrying a non-zero
// Burst cannot be honored and must be rejected rather than marshaled into a rule
// that reads back with Burst 0 and fails rule-identity comparison.
func TestPFRateLimitBurstRejected(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
_, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}})
require.ErrorIs(t, err, ErrUnsupported, "a rate-limit burst must be rejected, not silently dropped")
// A burst-less rate limit still round-trips.
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept,
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}}
line, err := fw.MarshalRule(orig)
require.NoError(t, err)
got, err := fw.UnmarshalRule(line)
require.NoError(t, err)
require.True(t, got.EqualBase(orig, true), "line %q", line)
}
// A pf label (user comment) containing consecutive spaces must round-trip: the
// line tokenizer collapses whitespace, so the label is recovered from the raw
// line rather than the split tokens.
func TestPFLabelConsecutiveSpaces(t *testing.T) {
fw := &PF{anchor: "go_firewall"}
for _, comment := range []string{"web server", "a b c", `has "quote" and spaces`} {
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: comment}
line, err := fw.MarshalRule(orig)
require.NoError(t, err)
got, err := fw.UnmarshalRule(line)
require.NoError(t, err, "line %q", line)
require.Equal(t, comment, got.Comment, "label whitespace must survive; line %q", line)
}
}
// A pf rule written per family lives in two anchor rows. RemoveRule/MoveRule must
// locate both from a FamilyAny target with EqualForRemoval, not the family-strict
// Equal — which matches neither, so the port stays open. Regression for the pf remove
// no-op on a family-agnostic target.
func TestPFFamilyAnyTargetMatchesBothTwins(t *testing.T) {
f := &PF{anchor: "go_firewall"}
v4, err := f.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22")
require.NoError(t, err)
v6, err := f.UnmarshalRule("pass in quick inet6 proto tcp from any to any port = 22")
require.NoError(t, err)
// The two rows cover the family-agnostic rule between them.
target := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept, Direction: DirInput}
require.True(t, target.CoveredBy([]*Rule{v4, v6}))
// The family-strict matcher finds neither physical row.
require.False(t, target.Equal(v4, true))
require.False(t, target.Equal(v6, true))
// EqualForRemoval finds both, so RemoveRule clears both anchor rows and MoveRule
// can locate the rule.
require.True(t, v4.EqualForRemoval(target, true))
require.True(t, v6.EqualForRemoval(target, true))
}
// MoveRule must relocate every physical row a target covers, not just the first.
// Moving only the first row of a v4/v6 pair leaves the twin at the earlier index —
// which then wins on the next read, making a move to a LATER position a silent no-op.
// reorderRows moves the covered rows as a block. Regression for the pf MoveRule
// family-pair defect.
func TestPFReorderRowsFamilyPair(t *testing.T) {
fw := new(PF)
mk := func(fam Family, port uint16) *Rule {
return &Rule{Family: fam, Proto: TCP, Port: port, Action: Accept}
}
// Physical anchor rows: A is a v4/v6 pair (rows 0,1); B is a v4/v6 pair (rows 2,3).
// GetRules reports four rules, numbered 1..4.
rules := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(IPv4, 80), mk(IPv6, 80)}
raw := []string{"A_v4", "A_v6", "B_v4", "B_v6"}
// Move both A rows past B. Once A is pulled out, two rows remain, so position 3 is
// past the end and appends.
out, moved := fw.reorderRows(rules, raw, mk(FamilyAny, 22), 3)
require.True(t, moved)
require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out,
"both rows the target covers must move together, landing after B")
// Move both B rows up to the front.
out, moved = fw.reorderRows(rules, raw, mk(FamilyAny, 80), 1)
require.True(t, moved)
require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out)
// A concrete-family target relocates only its own family row, never the twin.
// Three rows remain after A_v4 is pulled out, so position 4 appends.
out, moved = fw.reorderRows(rules, raw, mk(IPv4, 22), 4)
require.True(t, moved)
require.Equal(t, []string{"A_v6", "B_v4", "B_v6", "A_v4"}, out)
// A rule that matches nothing reports no move (MoveRule then skips the reload).
_, moved = fw.reorderRows(rules, raw, mk(FamilyAny, 443), 1)
require.False(t, moved)
}
// pf has no both-transports rule form: pfctl expands a `{ tcp udp }` list into
// separate rows on load, so the write path fans a TCPUDP rule into a tcp row and a
// udp row before marshalling. A TCPUDP rule reaching the row-level marshaller means
// that fan-out was skipped, so MarshalRule and MarshalNATRule must reject it rather
// than emit a `proto tcpudp` line pfctl cannot load. ProtocolAny+ports stays rejected
// as a separate, distinct case (see TestPFRules).
func TestPFMarshalRejectsTCPUDP(t *testing.T) {
f := &PF{anchor: "go_firewall"}
_, err := f.MarshalRule(&Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept})
require.Error(t, err, "a TCPUDP rule must not reach the row-level marshaller")
// No backend expresses NAT on both transports as one rule, so a TCPUDP nat rule
// can never round-trip and must be rejected too.
_, err = f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCPUDP, Port: 80, ToAddress: "10.0.0.5"})
require.ErrorIs(t, err, ErrUnsupported, "a TCPUDP nat rule must be rejected")
}
// expandProtocols fans a TCPUDP rule into a tcp rule and a udp rule, each of which
// marshals to a valid concrete-protocol pf line and round-trips. pf has no both-
// transports form, so the write path fans out before the row-level marshaller.
func TestPFExpandProtocolsMarshal(t *testing.T) {
f := &PF{anchor: "go_firewall"}
subs := expandProtocols(&Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept})
require.Len(t, subs, 2, "TCPUDP must fan into two concrete-transport rules")
require.Equal(t, TCP, subs[0].Proto)
require.Equal(t, UDP, subs[1].Proto)
for _, sub := range subs {
line, err := f.MarshalRule(sub)
require.NoError(t, err, "each fanned transport must marshal")
parsed, err := f.UnmarshalRule(line)
require.NoError(t, err, "line %q", line)
require.True(t, parsed.Equal(sub, true), "round-trip mismatch for %q", line)
}
}
// pfctl stores the two transports as separate rows, and pf's grammar has no both-
// transports form, so a TCPUDP rule reads back as a tcp row and a udp row. The pair
// covers the rule that was written; a lone tcp row never does.
func TestPFSeparateTransportRowsCoverTCPUDP(t *testing.T) {
f := &PF{anchor: "go_firewall"}
tcp, err := f.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22")
require.NoError(t, err)
udp, err := f.UnmarshalRule("pass in quick inet proto udp from any to any port = 22")
require.NoError(t, err)
require.Equal(t, TCP, tcp.Proto, "each row keeps its own concrete transport")
require.Equal(t, UDP, udp.Proto)
both := &Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept, Direction: DirInput}
require.True(t, both.CoveredBy([]*Rule{tcp, udp}))
require.False(t, both.CoveredBy([]*Rule{tcp}), "an unpaired tcp row must not cover a TCPUDP rule")
// The TCPUDP target reaches both rows on removal.
require.True(t, tcp.EqualForRemoval(both, true))
require.True(t, udp.EqualForRemoval(both, true))
}
// Every modeled anchor row is its own rule, so filterAnchors is the identity over
// them. An opaque (nil) row — an anchor line pf keeps but this backend cannot model —
// occupies a physical slot without consuming a logical position, so the rules after
// it must still map to their own physical rows.
func TestPFFilterAnchorsSkipOpaqueRows(t *testing.T) {
fw := new(PF)
mk := func(proto Protocol, port uint16) *Rule {
return &Rule{Family: IPv4, Proto: proto, Port: port, Action: Accept}
}
rules := []*Rule{mk(TCP, 22), mk(UDP, 22), mk(TCP, 80), mk(UDP, 80)}
require.Equal(t, []int{0, 1, 2, 3}, fw.filterAnchors(rules),
"every modeled row is its own anchor")
require.Equal(t, 1, logicalInsertIndex(fw.filterAnchors(rules), len(rules), 2))
require.Equal(t, 4, logicalInsertIndex(fw.filterAnchors(rules), len(rules), 5),
"a position past the last logical rule appends")
// An unmodeled line sits at physical row 1, shifting the rows after it.
withOpaque := []*Rule{mk(TCP, 22), nil, mk(UDP, 22), mk(TCP, 80)}
anchors := fw.filterAnchors(withOpaque)
require.Equal(t, []int{0, 2, 3}, anchors, "the opaque row consumes no logical position")
require.Equal(t, 2, logicalInsertIndex(anchors, len(withOpaque), 2),
"logical rule 2 lives at physical row 2, past the opaque line")
}
// reorderRows must relocate every physical row a target covers together: pfctl stores
// tcp and udp as separate rows, so a caller moving a TCPUDP rule (matched via
// EqualForRemoval's protocol coverage) moves both. A concrete-transport target moves
// only its own transport row, never the twin's.
func TestPFReorderRowsTransportPair(t *testing.T) {
fw := new(PF)
mk := func(proto Protocol, port uint16) *Rule {
return &Rule{Family: IPv4, Proto: proto, Port: port, Action: Accept}
}
rules := []*Rule{mk(TCP, 22), mk(UDP, 22), mk(TCP, 80), mk(UDP, 80)}
raw := []string{"A_tcp", "A_udp", "B_tcp", "B_udp"}
// Move both A rows past B. Two rows remain once A is pulled out, so position 3
// appends.
out, moved := fw.reorderRows(rules, raw, mk(TCPUDP, 22), 3)
require.True(t, moved)
require.Equal(t, []string{"B_tcp", "B_udp", "A_tcp", "A_udp"}, out,
"both transport rows the target covers must move together, landing after B")
// A concrete-transport target relocates only its own transport row. Three rows
// remain, so position 4 appends.
out, moved = fw.reorderRows(rules, raw, mk(TCP, 22), 4)
require.True(t, moved)
require.Equal(t, []string{"A_udp", "B_tcp", "B_udp", "A_tcp"}, out)
}
// writeFileLines must preserve the original file's mode (not loosen it to 0644)
// and must not leave a fixed-name temp file behind.
func TestWriteFileLinesPreservesMode(t *testing.T) {
fw := new(PF)
dir := t.TempDir()
path := filepath.Join(dir, "pf.conf")
require.NoError(t, os.WriteFile(path, []byte("old\n"), 0600))
require.NoError(t, fw.writeFileLines(path, []string{"line1", "line2"}))
// Content replaced.
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "line1\nline2\n", string(data))
// Mode preserved, not widened to 0644.
fi, err := os.Stat(path)
require.NoError(t, err)
require.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "mode must be preserved")
// No stale fixed-name temp file (the old fixed "<path>.tmp" scheme) remains.
_, err = os.Stat(path + ".tmp")
require.True(t, os.IsNotExist(err), "fixed-name temp file must not linger")
// A brand-new file defaults to 0600 rather than 0644.
newPath := filepath.Join(dir, "new.conf")
require.NoError(t, fw.writeFileLines(newPath, []string{"x"}))
fi, err = os.Stat(newPath)
require.NoError(t, err)
require.Equal(t, os.FileMode(0600), fi.Mode().Perm())
}
// readFileLines must handle a pf.conf line longer than bufio.Scanner's default
// 64 KB token cap rather than failing with a "token too long" error.
func TestReadFileLinesLongLine(t *testing.T) {
fw := new(PF)
dir := t.TempDir()
path := filepath.Join(dir, "pf.conf")
long := strings.Repeat("a", 300*1024) // 300 KB, well past the 64 KB default
require.NoError(t, os.WriteFile(path, []byte(long+"\nshort\n"), 0600))
lines, err := fw.readFileLines(path)
require.NoError(t, err, "a long line must not overflow the scanner")
require.Len(t, lines, 2)
require.Equal(t, long, lines[0])
require.Equal(t, "short", lines[1])
}
// pf reports PortList=false because pfctl expands a discrete port list
// (`port { 80 443 }`) into one rule per port on read, so a multi-port rule does
// not round-trip. MarshalRule must reject a destination-port list (as it already
// does for a source-port list and as the sibling PortList=false backends do)
// rather than emit a rule that reads back as several and churns Sync. A single
// contiguous range (PortRange=true) stays one token and must still be allowed.
func TestPFMarshalRejectsDestPortList(t *testing.T) {
f := &PF{anchor: "go_firewall"}
// A destination-port list must be rejected as unsupported.
_, err := f.MarshalRule(&Rule{
Proto: TCP,
Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}},
Action: Accept,
})
require.Error(t, err, "a destination-port list must be rejected")
require.ErrorIs(t, err, ErrUnsupported)
// A single contiguous range still round-trips as one token, so it is allowed.
line, err := f.MarshalRule(&Rule{
Proto: TCP,
Ports: []PortRange{{Start: 1000, End: 2000}},
Action: Accept,
})
require.NoError(t, err, "a single contiguous range must remain expressible")
require.Contains(t, line, "1000:2000")
}
// The NAT marshal path has the same constraint: a rdr/nat match-port list expands
// on read, so MarshalNATRule must reject a multi-port match rather than emit a
// list that reads back as several rules.
func TestPFMarshalNATRejectsPortList(t *testing.T) {
f := &PF{anchor: "go_firewall"}
_, err := f.MarshalNATRule(&NATRule{
Kind: DNAT,
Proto: TCP,
Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}},
ToAddress: "10.0.0.5",
})
require.Error(t, err, "a NAT match-port list must be rejected")
require.ErrorIs(t, err, ErrUnsupported)
}
// parseAddr must not mutate the caller's token slice when stripping a leading
// "!" negation.
func TestParsePFAddrDoesNotMutateTokens(t *testing.T) {
fw := new(PF)
tokens := []string{"!1.2.3.4", "port", "22"}
val, neg, next, err := fw.parseAddr(tokens, 0)
require.NoError(t, err)
require.Equal(t, "1.2.3.4", val)
require.Equal(t, "!", neg)
require.Equal(t, 0, next)
require.Equal(t, "!1.2.3.4", tokens[0], "the caller's slice must be left unchanged")
// The separate-"!" token form advances the index and leaves tokens intact.
tokens = []string{"!", "1.2.3.4"}
val, neg, next, err = fw.parseAddr(tokens, 0)
require.NoError(t, err)
require.Equal(t, "1.2.3.4", val)
require.Equal(t, "!", neg)
require.Equal(t, 1, next)
require.Equal(t, "!", tokens[0])
}