mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-16 13:17:19 +02:00
41ca69849a
Windows DNS intercept mode runs an NRPT health monitor that restores the catch-all rule and re-signals DNS Client whenever Windows stops routing queries to the local listener. When another agent (MDM, VPN, GPO) keeps putting NRPT back into a broken state, that loop never converges: ctrld repeatedly calls RefreshPolicyEx, Dnscache paramchange, and flushes the DNS cache, producing continuous flash writes and SIEM noise while never fixing anything. Add a recovery limiter that trips after a configurable number of consecutive recovery flows and enters a cooldown, during which recovery is suppressed (logged at most once every 5 minutes). Clearing the circuit requires two consecutive stable health successes rather than one, because a probe can pass briefly right after delete/re-add even when the underlying NRPT state is still broken. New [service] options gate the behavior and default to the previous unlimited behavior: - nrpt_recovery_max_attempts (default 0 = unlimited) - nrpt_recovery_cooldown (default 30m) Also collapse the repeated refresh + paramchange + flush sequence into a single signalNRPTChange() helper, and make cleanGPPath / cleanEmptyNRPTParent only mutate the registry and report whether cleanup happened, so callers send exactly one DNS Client change signal instead of several. When the GP DnsPolicyConfig parent exists but is empty, nrptProbeAndHeal now cleans it and signals once before spending the normal policy-refresh retry budget, since those retries cannot succeed while DNS Client is stuck in GP mode. Add unit tests for the limiter's cooldown, stable-reset, and unlimited paths, and document the new options and the empty-GP repro.
102 lines
2.2 KiB
Go
102 lines
2.2 KiB
Go
//go:build windows
|
|
|
|
package cli
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
)
|
|
|
|
const (
|
|
// Default to current behavior: keep recovering indefinitely unless configured.
|
|
defaultNRPTRecoveryMaxAttempts = 0
|
|
defaultNRPTRecoveryCooldown = 30 * time.Minute
|
|
|
|
// Require more than one good health tick before clearing the circuit. A probe can
|
|
// pass briefly after delete/re-add even when another agent recreates broken NRPT state.
|
|
nrptRecoveryStableSuccessesToReset = 2
|
|
)
|
|
|
|
type nrptRecoveryLimiter struct {
|
|
mu sync.Mutex
|
|
attempts int
|
|
stableSuccesses int
|
|
cooldownUntil time.Time
|
|
lastSkipLog time.Time
|
|
}
|
|
|
|
func nrptRecoveryMaxAttempts(cfg *ctrld.Config) int {
|
|
if cfg != nil && cfg.Service.NRPTRecoveryMaxAttempts != nil {
|
|
return *cfg.Service.NRPTRecoveryMaxAttempts
|
|
}
|
|
return defaultNRPTRecoveryMaxAttempts
|
|
}
|
|
|
|
func nrptRecoveryCooldown(cfg *ctrld.Config) time.Duration {
|
|
if cfg != nil && cfg.Service.NRPTRecoveryCooldown != nil {
|
|
return *cfg.Service.NRPTRecoveryCooldown
|
|
}
|
|
return defaultNRPTRecoveryCooldown
|
|
}
|
|
|
|
func (l *nrptRecoveryLimiter) allow(now time.Time, cfg *ctrld.Config) (bool, time.Duration) {
|
|
maxAttempts := nrptRecoveryMaxAttempts(cfg)
|
|
if maxAttempts <= 0 {
|
|
return true, 0
|
|
}
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
if now.Before(l.cooldownUntil) {
|
|
return false, l.cooldownUntil.Sub(now)
|
|
}
|
|
return true, 0
|
|
}
|
|
|
|
func (l *nrptRecoveryLimiter) recordRecoveryFlow(now time.Time, cfg *ctrld.Config) {
|
|
maxAttempts := nrptRecoveryMaxAttempts(cfg)
|
|
if maxAttempts <= 0 {
|
|
return
|
|
}
|
|
|
|
cooldown := nrptRecoveryCooldown(cfg)
|
|
if cooldown <= 0 {
|
|
cooldown = defaultNRPTRecoveryCooldown
|
|
}
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
l.stableSuccesses = 0
|
|
l.attempts++
|
|
if l.attempts >= maxAttempts {
|
|
l.cooldownUntil = now.Add(cooldown)
|
|
}
|
|
}
|
|
|
|
func (l *nrptRecoveryLimiter) recordStableSuccess() {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
l.stableSuccesses++
|
|
if l.stableSuccesses >= nrptRecoveryStableSuccessesToReset {
|
|
l.attempts = 0
|
|
l.cooldownUntil = time.Time{}
|
|
l.lastSkipLog = time.Time{}
|
|
}
|
|
}
|
|
|
|
func (l *nrptRecoveryLimiter) shouldLogSkip(now time.Time) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
if l.lastSkipLog.IsZero() || now.Sub(l.lastSkipLog) >= 5*time.Minute {
|
|
l.lastSkipLog = now
|
|
return true
|
|
}
|
|
return false
|
|
}
|