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

603 lines
27 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 merged 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")
require.False(t, fw.Capabilities().Forward, "pf does not advertise forward support")
}
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 IPv4/IPv6 filter twin is collapsed by mergeFamilies into one FamilyAny
// rule (impliedFamily FamilyAny). RemoveRule/MoveRule must locate that read-back
// rule with EqualForRemoval, not the family-strict Equal — which never matches
// it, so the port stays open. Regression for the pf remove no-op on a merged rule.
func TestPFMergedFilterTwinIsRemovable(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)
merged := mergeFamiliesCopy([]*Rule{v4, v6})
require.Len(t, merged, 1, "the v4/v6 twin must collapse into one rule")
m := merged[0]
require.Equal(t, FamilyAny, m.impliedFamily())
// The old family-strict matcher could find neither physical row.
require.False(t, m.Equal(v4, true))
require.False(t, m.Equal(v6, true))
// The new matcher (EqualForRemoval) finds both, so RemoveRule clears both anchor
// rows and MoveRule can locate the rule.
require.True(t, v4.EqualForRemoval(m, true))
require.True(t, v6.EqualForRemoval(m, true))
}
// MoveRule must relocate BOTH physical rows of a merged IPv4/IPv6 pair, not just
// the first. mergeFamilies re-pairs rows on the lower physical index, so moving only
// the first row of the 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 pair as a block. Regression for the pf MoveRule merged-pair defect.
func TestPFReorderRowsMergedPair(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 merges these to A(Number 1), B(Number 2).
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 merged A (read back as a collapsed FamilyAny pair) to position 2 — after B.
out, moved := fw.reorderRows(rules, raw, mk(FamilyAny, 22), 2)
require.True(t, moved)
require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out,
"both rows of the pair must move together, landing after B")
// Move merged B up to the front (position 1) — the previously-working direction.
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.
out, moved = fw.reorderRows(rules, raw, mk(IPv4, 22), 3)
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)
}
// 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])
}