603 lines
27 KiB
Go
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])
|
|
}
|