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