mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
158 lines
5.1 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|