Files
ctrld/cmd/cli/pf_ruleset_test.go
T

158 lines
5.1 KiB
Go

package cli
import "testing"
// altqNoise is what macOS pfctl writes to stderr on show commands. Because every
// pfctl reader here uses CombinedOutput, it lands in the middle of the data being
// parsed — which is why these helpers exist.
const altqNoise = "No ALTQ support in kernel\nALTQ related functions disabled\n"
// TestPFRulesetEmpty is the regression guard for a flushed anchor being undetectable.
//
// The anchor-content checks in verifyPFState and ensurePFAnchorActive decide whether pf
// still has ctrld's rules. Testing the raw pfctl output for emptiness can never be true
// on macOS, because the merged ALTQ warnings are always present — so a genuinely flushed
// anchor reads as healthy and neither the startup gate nor the watchdog restore fires.
func TestPFRulesetEmpty(t *testing.T) {
tests := []struct {
name string
output string
want bool
}{
{
// The case that was broken: nothing but merged stderr.
name: "only ALTQ warnings",
output: altqNoise,
want: true,
},
{
// As captured on macOS 26.6 from "pfctl -sn -a com.controld.ctrld".
name: "ALTQ warnings plus the empty-ruleset marker",
output: altqNoise + "(no rules)\n",
want: true,
},
{
name: "empty output",
output: "",
want: true,
},
{
name: "whitespace only",
output: "\n \n\t\n",
want: true,
},
{
name: "a real rdr rule behind the warnings",
output: altqNoise + "rdr on lo0 inet proto udp from any to ! 127.0.0.1 port = 53 -> 127.0.0.1 port 5354\n",
want: false,
},
{
name: "a real filter rule behind the warnings",
output: altqNoise + "pass in quick on lo0 reply-to lo0 inet proto udp from any to 127.0.0.1 port = 5354\n",
want: false,
},
{
name: "rule with no warnings at all",
output: "anchor \"com.controld.ctrld\" all\n",
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := pfRulesetEmpty(tc.output); got != tc.want {
t.Errorf("pfRulesetEmpty() = %v, want %v\noutput:\n%s", got, tc.want, tc.output)
}
})
}
}
// TestPFFilterRuleLines checks what survives filtering, since these lines are fed back
// into "pfctl -f -" by the ruleset-rebuild paths. Splicing a warning or the
// empty-ruleset marker into a ruleset would have the reload rejected outright.
func TestPFFilterRuleLines(t *testing.T) {
got := pfFilterRuleLines(altqNoise + "(no rules)\nrdr-anchor \"com.controld.ctrld\" all\n\nanchor \"com.controld.ctrld\" all\n")
want := []string{
`rdr-anchor "com.controld.ctrld" all`,
`anchor "com.controld.ctrld" all`,
}
if len(got) != len(want) {
t.Fatalf("got %d lines %q, want %d %q", len(got), got, len(want), want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("line %d = %q, want %q", i, got[i], want[i])
}
}
if lines := pfFilterRuleLines(altqNoise); lines != nil {
t.Errorf("warnings alone must yield no rule lines, got %q", lines)
}
}
// TestPFAnchorReferencesPresent guards when the main ruleset may be rewritten.
//
// Removing our anchor references means reloading the whole main ruleset, and that
// reload carries no options section — so it resets system-wide pf options, including
// third-party "set skip" directives. Startup rollback runs after failures that happen
// before the references were ever added, so without this check it would reset another
// application's pf options while removing nothing of ours.
func TestPFAnchorReferencesPresent(t *testing.T) {
const anchor = "com.controld.ctrld"
const otherAppRules = "scrub-anchor \"com.apple/*\" all fragment reassemble\nanchor \"com.vendor.vpn\" all\n"
tests := []struct {
name string
nat string
filter string
want bool
}{
{
name: "both references present",
nat: altqNoise + "rdr-anchor \"com.controld.ctrld\" all\n",
filter: altqNoise + "anchor \"com.controld.ctrld\" all\n",
want: true,
},
{
// pfctl appends tokens like " all", so matching is substring-based.
name: "rdr reference only",
nat: altqNoise + "rdr-anchor \"com.controld.ctrld\" all\n",
filter: altqNoise + otherAppRules,
want: true,
},
{
name: "filter reference only",
nat: altqNoise,
filter: altqNoise + "anchor \"com.controld.ctrld\"\n",
want: true,
},
{
// The rollback case: we failed before adding anything, and another
// application owns the ruleset. Rewriting it would be pure collateral.
name: "someone else's ruleset, none of ours",
nat: altqNoise,
filter: altqNoise + otherAppRules,
want: false,
},
{
name: "empty ruleset",
nat: altqNoise + "(no rules)\n",
filter: altqNoise + "(no rules)\n",
want: false,
},
{
// A different anchor whose name merely contains ours must not count.
name: "another anchor with a similar name",
nat: altqNoise,
filter: altqNoise + "anchor \"com.vendor.controld-shim\" all\n",
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := pfAnchorReferencesPresent(tc.nat, tc.filter, anchor); got != tc.want {
t.Errorf("pfAnchorReferencesPresent() = %v, want %v", got, tc.want)
}
})
}
}