mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
320 lines
13 KiB
Go
320 lines
13 KiB
Go
//go:build windows
|
|
|
|
package cli
|
|
|
|
import (
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// newInterceptTestProg returns a prog with a published intercept state, fake NRPT
|
|
// operations already installed, and no WFP engine (engineHandle 0).
|
|
//
|
|
// The fake is installed here, before anything can inspect registry state, and it is the
|
|
// safety boundary - not the empty wfpState. A zero-valued state has owner None, and
|
|
// shutdown's None branch sweeps orphaned ctrld rules, so an unfaked stopDNSIntercept would
|
|
// reach the production nrptCatchAllRuleExists / removeNRPTCatchAllRule / signalNRPTChange.
|
|
// On a host that has ctrld's deterministic key - a developer box, or a CI runner where
|
|
// ctrld is installed - that deletes live policy and forces a Group Policy refresh, a
|
|
// Dnscache paramchange and a cache flush. A green run on a clean runner proves nothing
|
|
// about that.
|
|
func newInterceptTestProg(t *testing.T) (*prog, *wfpState, *fakeNRPTOps) {
|
|
t.Helper()
|
|
f := fakeNRPTOpsForTest(t)
|
|
// Prove the fake is in effect before anything can inspect registry state. Asserting
|
|
// zero side effects afterwards cannot do that: an uninstalled fake reports zero
|
|
// whether it was consulted or bypassed.
|
|
requireFakeNRPTOpsInstalled(t, f)
|
|
state := &wfpState{stopCh: make(chan struct{}), listenerIP: "127.0.0.1"}
|
|
p := &prog{}
|
|
p.dnsInterceptState = state
|
|
return p, state, f
|
|
}
|
|
|
|
// assertNoNRPTSideEffects fails when a lifecycle path wrote NRPT policy or signalled the
|
|
// DNS Client. Every test in this file exercises a guard that is supposed to stand down, so
|
|
// any registry write or signal here means the guard did not hold - and, without the fake,
|
|
// would have hit the host's real policy.
|
|
func assertNoNRPTSideEffects(t *testing.T, f *fakeNRPTOps) {
|
|
t.Helper()
|
|
add, remove, signal, _ := f.counts()
|
|
if add != 0 || remove != 0 || signal != 0 {
|
|
t.Errorf("addRule = %d, removeRule = %d, signal = %d, want 0/0/0: this path must not write NRPT policy",
|
|
add, remove, signal)
|
|
}
|
|
if flush := f.flushCount(); flush != 0 {
|
|
t.Errorf("flush calls = %d, want 0: this path must not flush the resolver cache", flush)
|
|
}
|
|
}
|
|
|
|
// TestStopDNSInterceptRevokesBeforeTeardown pins the ordering the shutdown/monitor race
|
|
// depends on. Teardown deletes our WFP sublayer, and a missing sublayer is precisely what
|
|
// the health monitor treats as "our filters were wiped, rebuild everything". Were the
|
|
// state revoked only after teardown, a monitor tick inside that window would rebuild the
|
|
// intercept during shutdown.
|
|
func TestStopDNSInterceptRevokesBeforeTeardown(t *testing.T) {
|
|
p, state, f := newInterceptTestProg(t)
|
|
defer assertNoNRPTSideEffects(t, f)
|
|
|
|
if p.interceptStateRevoked(state) {
|
|
t.Fatal("a freshly published intercept state must not read as retired")
|
|
}
|
|
if err := p.stopDNSIntercept(); err != nil {
|
|
t.Fatalf("stopDNSIntercept() = %v", err)
|
|
}
|
|
if !p.interceptStateRevoked(state) {
|
|
t.Error("state still reads live after shutdown: the monitor and heal flows would keep writing host DNS state")
|
|
}
|
|
if p.dnsInterceptState != nil {
|
|
t.Error("dnsInterceptState survived shutdown")
|
|
}
|
|
if p.dnsInterceptStopRequested.Load() {
|
|
t.Error("stop-requested flag was left set; a later start would see a phantom shutdown")
|
|
}
|
|
}
|
|
|
|
// TestRebuildDNSInterceptRefusedAfterShutdown is the regression test for the reported
|
|
// race: SCM stop runs resetDNS -> stopDNSIntercept while the health monitor is mid-tick,
|
|
// and the monitor then reaches the rebuild path before the process exits. The rebuild
|
|
// must refuse - completing it would re-add the NRPT catch-all and the WFP filters moments
|
|
// before ctrld disappears, leaving Windows resolving through a listener that is gone.
|
|
//
|
|
// That refusal is also what keeps this test safe on a real Windows host: a rebuild that
|
|
// did not refuse would run startDNSIntercept and write NRPT policy to the machine
|
|
// running the tests.
|
|
func TestRebuildDNSInterceptRefusedAfterShutdown(t *testing.T) {
|
|
p, state, f := newInterceptTestProg(t)
|
|
defer assertNoNRPTSideEffects(t, f)
|
|
if err := p.stopDNSIntercept(); err != nil {
|
|
t.Fatalf("stopDNSIntercept() = %v", err)
|
|
}
|
|
|
|
if got := p.rebuildDNSIntercept(state, "WFP sublayer missing during health check"); got != interceptRebuildRetired {
|
|
t.Fatalf("rebuildDNSIntercept() = %v, want interceptRebuildRetired - a post-shutdown rebuild resurrects DNS interception", got)
|
|
}
|
|
if p.dnsInterceptState != nil {
|
|
t.Error("rebuild published new intercept state after shutdown")
|
|
}
|
|
}
|
|
|
|
// TestRebuildDNSInterceptRefusedForReplacedState covers the other stale-owner case: an
|
|
// earlier rebuild already replaced the state, so a goroutine still holding the old one
|
|
// must not tear down its successor.
|
|
func TestRebuildDNSInterceptRefusedForReplacedState(t *testing.T) {
|
|
p, old, f := newInterceptTestProg(t)
|
|
defer assertNoNRPTSideEffects(t, f)
|
|
current := &wfpState{stopCh: make(chan struct{}), listenerIP: "127.0.0.1"}
|
|
p.dnsInterceptState = current
|
|
|
|
if got := p.rebuildDNSIntercept(old, "WFP sublayer missing during health check"); got != interceptRebuildRetired {
|
|
t.Fatalf("rebuildDNSIntercept() = %v, want interceptRebuildRetired for a superseded state", got)
|
|
}
|
|
if p.dnsInterceptState != any(current) {
|
|
t.Error("a superseded state's rebuild replaced the live intercept")
|
|
}
|
|
if p.interceptStateRevoked(current) {
|
|
t.Error("the live state was revoked by a superseded rebuild")
|
|
}
|
|
}
|
|
|
|
// TestRepairMissingWFPStandsDownAfterShutdown checks the monitor's entry point. It must
|
|
// not even query WFP for a retired state - the sublayer it looks for is what teardown
|
|
// just deleted - and it must tell the monitor goroutine to exit.
|
|
func TestRepairMissingWFPStandsDownAfterShutdown(t *testing.T) {
|
|
p, state, f := newInterceptTestProg(t)
|
|
defer assertNoNRPTSideEffects(t, f)
|
|
if err := p.stopDNSIntercept(); err != nil {
|
|
t.Fatalf("stopDNSIntercept() = %v", err)
|
|
}
|
|
// Set the handle only after teardown. A fake handle proves the revocation check
|
|
// comes first, but must never reach the real WFP calls in cleanupWFPFilters.
|
|
state.engineHandle = 1
|
|
|
|
if !p.repairMissingWFP(state) {
|
|
t.Error("repairMissingWFP() = false after shutdown; the health monitor would keep running for a dead intercept")
|
|
}
|
|
if p.dnsInterceptState != nil {
|
|
t.Error("repairMissingWFP rebuilt the intercept after shutdown")
|
|
}
|
|
}
|
|
|
|
// TestPendingStopSignalsRevocation covers how a stop avoids waiting: while it is blocked
|
|
// on the lifecycle lock it must already read as revoked, so an in-flight NRPT heal
|
|
// abandons its probe backoff instead of making the service stop wait it out. A stop that
|
|
// waits too long is killed by the Service Control Manager, which cleans up nothing.
|
|
func TestPendingStopSignalsRevocation(t *testing.T) {
|
|
p, state, f := newInterceptTestProg(t)
|
|
defer assertNoNRPTSideEffects(t, f)
|
|
|
|
p.dnsInterceptMu.Lock()
|
|
stopped := make(chan struct{})
|
|
go func() {
|
|
defer close(stopped)
|
|
_ = p.stopDNSIntercept()
|
|
}()
|
|
|
|
// Wait for the stop to announce itself while it is blocked on the lock.
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for !p.dnsInterceptStopRequested.Load() {
|
|
if time.Now().After(deadline) {
|
|
p.dnsInterceptMu.Unlock()
|
|
<-stopped
|
|
t.Fatal("stop never announced itself before waiting for the lifecycle lock")
|
|
}
|
|
runtime.Gosched()
|
|
}
|
|
if !p.interceptStateRevoked(state) {
|
|
t.Error("a pending stop does not read as revoked; the heal flows would keep it waiting")
|
|
}
|
|
p.dnsInterceptMu.Unlock()
|
|
<-stopped
|
|
|
|
if p.dnsInterceptState != nil {
|
|
t.Error("the pending stop did not tear down the intercept once it acquired the lock")
|
|
}
|
|
}
|
|
|
|
// TestInterceptWaitAbandonsPromptlyOnPendingStop is the bound on how long a stop can be
|
|
// delayed by a recovery flow: the heal sequence's waits add up to tens of seconds, and
|
|
// each one must end as soon as a stop is pending.
|
|
func TestInterceptWaitAbandonsPromptlyOnPendingStop(t *testing.T) {
|
|
p, state, f := newInterceptTestProg(t)
|
|
defer assertNoNRPTSideEffects(t, f)
|
|
p.dnsInterceptStopRequested.Store(true)
|
|
|
|
start := time.Now()
|
|
if p.interceptWait(state, 30*time.Second) {
|
|
t.Fatal("interceptWait() = true with a stop pending; the caller would carry on writing host DNS state")
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
|
t.Errorf("interceptWait took %v to notice a pending stop; shutdown would inherit that delay", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestInterceptWaitRunsToCompletionWhileLive guards the other direction: the cancellable
|
|
// wait must still actually wait, or the recovery flows lose their backoff.
|
|
func TestInterceptWaitRunsToCompletionWhileLive(t *testing.T) {
|
|
p, state, f := newInterceptTestProg(t)
|
|
defer assertNoNRPTSideEffects(t, f)
|
|
|
|
start := time.Now()
|
|
if !p.interceptWait(state, 250*time.Millisecond) {
|
|
t.Fatal("interceptWait() = false for a live intercept")
|
|
}
|
|
if elapsed := time.Since(start); elapsed < 250*time.Millisecond {
|
|
t.Errorf("interceptWait returned after %v, want at least 250ms", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestNRPTNeedsCtrldActivation covers the recovery gap that left a machine unfiltered
|
|
// until restart: a failed NRPT write clears ownership, and an owner-None tick used to do
|
|
// nothing at all, so nothing ever retried the write.
|
|
func TestNRPTNeedsCtrldActivation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
owner nrptRuleOwner
|
|
ruleExists bool
|
|
want bool
|
|
}{
|
|
{
|
|
// The reported hole: activation failed, ownership was cleared, and no
|
|
// other path re-arms it. In hard mode WFP keeps blocking DNS meanwhile.
|
|
name: "no owner retries the failed write",
|
|
owner: nrptRuleOwnerNone,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "no owner retries even if a rule is somehow present",
|
|
owner: nrptRuleOwnerNone,
|
|
ruleExists: true,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "ctrld-owned rule removed externally is re-added",
|
|
owner: nrptRuleOwnerCtrld,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "healthy ctrld-owned rule is left alone",
|
|
owner: nrptRuleOwnerCtrld,
|
|
ruleExists: true,
|
|
want: false,
|
|
},
|
|
{
|
|
// Writing beside external policy would be ambiguous policy, not recovery.
|
|
name: "external policy is never overwritten",
|
|
owner: nrptRuleOwnerGroupPolicy,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "external policy is never overwritten even with a ctrld rule present",
|
|
owner: nrptRuleOwnerGroupPolicy,
|
|
ruleExists: true,
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := nrptNeedsCtrldActivation(tc.owner, tc.ruleExists); got != tc.want {
|
|
t.Errorf("nrptNeedsCtrldActivation(%v, %v) = %v, want %v", tc.owner, tc.ruleExists, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestActivateCtrldNRPTFallbackRefusedAfterShutdown guards the worst leftover. A
|
|
// catch-all re-added after shutdown points every DNS query on the machine at a listener
|
|
// that no longer exists, so nothing resolves at all. Refusing early also keeps this test
|
|
// from writing NRPT policy on the machine running it.
|
|
func TestActivateCtrldNRPTFallbackRefusedAfterShutdown(t *testing.T) {
|
|
p, state, f := newInterceptTestProg(t)
|
|
defer assertNoNRPTSideEffects(t, f)
|
|
if err := p.stopDNSIntercept(); err != nil {
|
|
t.Fatalf("stopDNSIntercept() = %v", err)
|
|
}
|
|
|
|
if p.activateCtrldNRPTFallback(state, "ctrld-owned rule missing during health check") {
|
|
t.Error("activateCtrldNRPTFallback() = true after shutdown: the catch-all would outlive ctrld")
|
|
}
|
|
if owner, _ := state.nrptPolicyOwner(); owner != nrptRuleOwnerNone {
|
|
t.Errorf("NRPT owner = %v after a refused fallback, want nrptRuleOwnerNone", owner)
|
|
}
|
|
}
|
|
|
|
// TestAllowHandbackAttemptRateLimits covers the throttle on testing an external
|
|
// catch-all. Each attempt takes ctrld's rule out of the way for a probe, so a rule that
|
|
// never routes would cost a brief DNS outage on every 30s health tick without this - in
|
|
// hard mode a window where WFP blocks DNS and nothing redirects it.
|
|
func TestHandbackThrottleIsPerRule(t *testing.T) {
|
|
state := &wfpState{stopCh: make(chan struct{})}
|
|
now := time.Now()
|
|
|
|
if !state.handbackAllowed(now, "{GP-RULE}", nrptHandbackRetryInterval) {
|
|
t.Fatal("first handback attempt must be allowed")
|
|
}
|
|
// Checking alone must not spend the budget: a pre-probe can still abort the attempt
|
|
// without disturbing NRPT, and that must not cost the rule its next window.
|
|
if !state.handbackAllowed(now, "{GP-RULE}", nrptHandbackRetryInterval) {
|
|
t.Error("handbackAllowed must not consume the budget by itself")
|
|
}
|
|
|
|
state.recordHandbackAttempt(now, "{GP-RULE}", nrptHandbackRetryInterval)
|
|
if state.handbackAllowed(now.Add(nrptHandbackRetryInterval-time.Second), "{GP-RULE}", nrptHandbackRetryInterval) {
|
|
t.Error("re-testing the same rule inside the interval must be suppressed")
|
|
}
|
|
// Group Policy alternating between two names must not erase either one's memory:
|
|
// with a single slot every swap costs another removal of the live rule.
|
|
if !state.handbackAllowed(now.Add(time.Second), "{OTHER-RULE}", nrptHandbackRetryInterval) {
|
|
t.Error("a different rule name means the administrator changed policy: test it now")
|
|
}
|
|
state.recordHandbackAttempt(now.Add(time.Second), "{OTHER-RULE}", nrptHandbackRetryInterval)
|
|
if state.handbackAllowed(now.Add(2*time.Second), "{GP-RULE}", nrptHandbackRetryInterval) {
|
|
t.Error("testing another rule must not clear the first rule's throttle")
|
|
}
|
|
|
|
if !state.handbackAllowed(now.Add(2*nrptHandbackRetryInterval), "{GP-RULE}", nrptHandbackRetryInterval) {
|
|
t.Error("the same rule must be testable again after the interval")
|
|
}
|
|
}
|