//go:build darwin || freebsd package firewall import ( "os" "path/filepath" "strconv" "strings" "testing" "github.com/stretchr/testify/require" ) // 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: "203.0.113.10", Port: 4789, Proto: TCP, Action: Accept}, {Direction: DirOutput, Family: IPv4, Destination: "203.0.113.10", 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 . {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 ") // 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. require.Error(t, fw.validateRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}), "expected a port with no protocol to be rejected") // 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. require.NoError(t, fw.validateRule(&Rule{Proto: TCP, SourcePort: 1024, Action: Accept}), "a single source port is valid") require.NoError(t, fw.validateRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 2048}}, Action: Accept}), "a source-port range is valid") require.Error(t, fw.validateRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}), "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) } // A discrete destination-port list has no single-row pf form: pfctl expands // `port { 80 443 }` into one rule per port on load, so validateRule rejects it // and AddRule fans the list into one row per port with expandPorts instead. require.ErrorIs(t, fw.validateRule(&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}), ErrUnsupported, "a destination-port list must be rejected under pf") // pf cannot express a connection-state match in this model. require.Error(t, fw.validateRule(&Rule{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}), "expected a state match to be rejected under pf") // Interface/direction mismatches must be rejected. require.Error(t, fw.validateRule(&Rule{Direction: DirOutput, InInterface: "em0", Action: Accept}), "expected an input interface on an output rule to be rejected") require.Error(t, fw.validateRule(&Rule{OutInterface: "em0", Action: Accept}), "expected an output interface on an input rule to be rejected") // pf has no distinct forward chain, so a forward rule is rejected with the // ErrUnsupportedForward sentinel. require.ErrorIs(t, fw.validateRule(&Rule{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept}), 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. require.Error(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "x"}), "expected pf to reject a log prefix") require.Error(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}), "expected pf to reject a limit on a non-accept rule") require.Error(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}), "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. require.Error(t, fw.validateNAT(&NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}), "expected pf to reject a redirect") require.Error(t, fw.validateNAT(&NATRule{Kind: Masquerade, Family: IPv4}), "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"} require.ErrorIs(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}}), 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. The pf remove must not // 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. 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's nat translation maps to an address only; it has no source-port form, so a // SNAT rule carrying a ToPort is rejected here rather than dropped silently. The // shared validate accepts the shape, since iptables emits it as // --to-source addr:port, so the rejection lives in pf's own validateNAT. func TestPFMarshalRejectsSNATPort(t *testing.T) { f := &PF{anchor: "go_firewall"} require.ErrorIs(t, f.validateNAT(&NATRule{Kind: SNAT, Proto: TCP, ToAddress: "192.0.2.1", ToPort: 80}), ErrUnsupportedNAT, "a source-port SNAT must be rejected") // A portless SNAT still marshals. require.NoError(t, f.validateNAT(&NATRule{Kind: SNAT, ToAddress: "192.0.2.1"})) _, err := f.MarshalNATRule(&NATRule{Kind: SNAT, ToAddress: "192.0.2.1"}) require.NoError(t, err) } // 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) } } // 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, fw.logicalInsertIndex(fw.filterAnchors(rules), len(rules), 2)) require.Equal(t, 4, fw.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, fw.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 ".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]) } // MarshalRule must reject a destination-port list (as it already does for a // source-port list): pfctl expands a discrete port list (`port { 80 443 }`) // into one rule per port on load, so a list has no single-row form. AddRule // fans a list into one row per port with expandPorts, so the row-level // marshaller only ever sees a single spec. A single contiguous range 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.validateRule(&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") } // 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]) } // A verbose listing prints a counters continuation under every rule, including an // unmodeled one held as an opaque nil row; attaching those counters must not // dereference the nil slot. func TestPFVerboseCountersAfterUnmodeledRow(t *testing.T) { fw := &PF{anchor: "go_firewall"} out := []string{ "pass in quick inet proto tcp from any to any port > 1023 keep state", " [ Evaluations: 100 Packets: 40 Bytes: 2400 States: 2 ]", "pass in quick inet proto udp from any to any port = 53 keep state", " [ Evaluations: 5 Packets: 5 Bytes: 300 States: 0 ]", } rules, raw := fw.parseAnchorRules(out) require.Len(t, rules, 2) require.Len(t, raw, 2) require.Nil(t, rules[0], "the unmodeled line stays an opaque row") require.NotNil(t, rules[1]) require.EqualValues(t, 5, rules[1].Packets, "counters still attach to the modeled rule") } // A portable backup may carry DirAny or TCPUDP rules captured from a backend that // stores them as one row; marshalExpanded (behind Restore) must fan them out // exactly as the add paths do rather than fail or drop a half. func TestPFMarshalExpandedMergedRules(t *testing.T) { fw := &PF{anchor: "go_firewall"} merged := &Rule{Direction: DirAny, Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept} lines, err := fw.marshalExpanded([]*Rule{merged}) require.NoError(t, err) require.Len(t, lines, 4, "DirAny x TCPUDP must expand to four rows") var in, out, tcp, udp int for _, l := range lines { if strings.Contains(l, "pass in ") { in++ } if strings.Contains(l, "pass out ") { out++ } if strings.Contains(l, "proto tcp") { tcp++ } if strings.Contains(l, "proto udp") { udp++ } } require.Equal(t, 2, in) require.Equal(t, 2, out) require.Equal(t, 2, tcp) require.Equal(t, 2, udp) // A concrete rule passes through unchanged. lines, err = fw.marshalExpanded([]*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}}) require.NoError(t, err) require.Len(t, lines, 1) } // A nat source-port match and a dynamic-target port pool have no faithful model; // both must error so the line is preserved as an opaque row rather than mis-read. func TestPFNATParserKeepsUnmodeledShapesOpaque(t *testing.T) { fw := &PF{anchor: "go_firewall"} opaque := []string{ "nat on em0 inet from 10.0.0.0/8 port = 500 to any -> 192.0.2.1", "nat on em0 inet from 10.0.0.0/8 to any -> (em0) port 1024:65535", } for _, line := range opaque { _, err := fw.UnmarshalNATRule(line) require.Error(t, err, "line must stay opaque: %s", line) } // The plain forms still parse. r, err := fw.UnmarshalNATRule("nat on em0 inet from 10.0.0.0/8 to any -> (em0)") require.NoError(t, err) require.Equal(t, Masquerade, r.Kind) r, err = fw.UnmarshalNATRule("rdr on em0 inet proto tcp from any to any port = 8080 -> 10.0.0.5 port 80") require.NoError(t, err) require.Equal(t, DNAT, r.Kind) require.EqualValues(t, 8080, r.Port) require.EqualValues(t, 80, r.ToPort) } // insertRows must place every cell of a merged rule in one pass over the anchor's // rows, so a rule spanning directions and transports is written by a single anchor // load rather than one read-rewrite cycle per cell. The cells go in at the // requested index in the order ruleCells fans them out, and the surrounding rows // keep their positions. func TestPFInsertRowsPlacesEveryCell(t *testing.T) { fw := &PF{anchor: "go_firewall"} merged := &Rule{Direction: DirAny, Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept} cells := fw.ruleCells(merged) require.Len(t, cells, 4, "DirAny x TCPUDP occupies four anchor rows") // An empty anchor: with no placement point every cell appends. out, changed, err := fw.insertRows(nil, nil, cells, func(int, *Rule) bool { return false }) require.NoError(t, err) require.True(t, changed) require.Len(t, out, 4, "all four rows must land in the one pass") // A populated anchor with the insert predicate selecting physical row 1: the // block goes in ahead of that row, leaving the existing rows in order. existing := []*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, {Family: IPv4, Proto: TCP, Port: 80, Action: Accept}} raw := []string{"first", "second"} out, changed, err = fw.insertRows(existing, raw, cells, func(i int, _ *Rule) bool { return i == 1 }) require.NoError(t, err) require.True(t, changed) require.Len(t, out, 6) require.Equal(t, "first", out[0]) require.Equal(t, "second", out[5], "the rows either side of the insert keep their order") want := make([]string, len(cells)) for i, cell := range cells { line, mErr := fw.MarshalRule(cell) require.NoError(t, mErr) want[i] = line } require.Equal(t, want, out[1:5], "the cells go in together, in ruleCells order") } // The add-time dedup must ask whether an existing row *covers* the cell, not // whether it is exactly equal: an anchor row written without an af matches both // families, so re-adding its IPv4 half would leave a redundant row Sync then // reports forever. The coverage is one-way, so a concrete-family row must not // swallow its opposite-family twin — that would leave the twin's family un-firewalled. func TestPFInsertRowsDedupUsesCoverage(t *testing.T) { fw := &PF{anchor: "go_firewall"} never := func(int, *Rule) bool { return false } // An af-less row covers the concrete-family cell it spans. dual, err := fw.UnmarshalRule("pass in quick proto tcp from any to any port = 22") require.NoError(t, err) v4Cell := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 22, Action: Accept} _, changed, err := fw.insertRows([]*Rule{dual}, []string{"dual"}, []*Rule{v4Cell}, never) require.NoError(t, err) require.False(t, changed, "a row covering the cell must not be duplicated") // A concrete IPv4 row does not cover the IPv6 cell, which must still be written. v4Row, err := fw.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22") require.NoError(t, err) v6Cell := &Rule{Family: IPv6, Direction: DirInput, Proto: TCP, Port: 22, Action: Accept} out, changed, err := fw.insertRows([]*Rule{v4Row}, []string{"v4"}, []*Rule{v6Cell}, never) require.NoError(t, err) require.True(t, changed, "an opposite-family twin is not a duplicate") require.Len(t, out, 2) require.Contains(t, out[1], "inet6") // A partially-present set fills in only the missing cell rather than re-adding // the rule whole, so an edit interrupted part-way converges on a retry. missing := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 80, Action: Accept} out, changed, err = fw.insertRows([]*Rule{v4Row}, []string{"v4"}, []*Rule{v4Cell, missing}, never) require.NoError(t, err) require.True(t, changed) require.Len(t, out, 2, "only the absent cell is added") require.Contains(t, out[1], "port 80") } // A concrete-family removal that matches a genuine dual-family row (an anchor rule // with no af, covering both) must not drop both families: the untargeted family is // re-marshalled into the removed row's own slot, so it keeps its coverage and its // place in the anchor. Opaque rows are never removal targets and stay put. func TestPFRemoveRowsSplitsDualRowInPlace(t *testing.T) { fw := &PF{anchor: "go_firewall"} dual, err := fw.UnmarshalRule("pass in quick proto tcp from any to any port = 22") require.NoError(t, err) tail := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 80, Action: Accept} rules := []*Rule{nil, dual, tail} raw := []string{"opaque", "dual", "tail"} cell := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 22, Action: Accept} out, changed, err := fw.removeRows(rules, raw, []*Rule{cell}) require.NoError(t, err) require.True(t, changed) require.Len(t, out, 3, "the dual row is replaced, not dropped") require.Equal(t, "opaque", out[0], "an opaque row is never a removal target") require.Contains(t, out[1], "inet6", "the untargeted family survives in the dual row's slot") require.Equal(t, "tail", out[2]) // A target matching nothing leaves the rows untouched, so no anchor load runs. _, changed, err = fw.removeRows(rules, raw, []*Rule{{Family: IPv4, Direction: DirInput, Proto: UDP, Port: 9999, Action: Accept}}) require.NoError(t, err) require.False(t, changed) }