Files
ctrld/cmd/cli/query_health_prog_test.go
T
Cuong Manh Le 5978ddfe35 cli: add network fallback and explicit-only Internal Domain modes
Internal Domains now support three modes. "os" (network default) is
unchanged. "resolvers" becomes explicit resolver with network fallback,
the default explicit selection: the configured resolvers are tried
first, and a timeout, unreachable resolver, SERVFAIL, NXDOMAIN, REFUSED
or NOTIMP hands the query to the network resolvers - matching VPN DNS
servers, then domain-less VPN DNS servers, then the OS resolver's LAN
nameservers. A valid empty answer stays final. "resolvers_only" keeps
the previous strict behavior.

The OS step uses a new LanOnlyQueryCtx: the OS resolver then asks only
its LAN-classified nameservers (private, loopback, link-local, CGNAT)
and sends nothing when it has none, so the private name never reaches a
public nameserver, whether DHCP supplied it or it is ctrld's own public
fallback. LanQueryCtx only dropped the latter. LAN-only queries get
their own singleflight and hot-cache key, so they are never answered
with an ordinary query's public answer.

In fallback mode a configured resolver that the upstream monitor has
marked down is skipped instead of waited on, so an endpoint off the
organization network reaches the fallback without paying the
resolver's 2s timeout on every query. Nothing else marks a generated
resolver up again, so skipping one re-checks it in the background, at
most once every 30s; an answer marks it up. Strict mode never skips.

An absent mode with resolvers gets the fallback mode. An unrecognized
mode with resolvers fails closed to explicit resolver only, so a wire
value this build does not know never sends the domain to the Control D
upstream; without resolvers it is dropped. Switching between the
explicit modes is detected on refresh.

Generated upstreams are now identified by an UpstreamConfig.InternalDomain
marker that records their mode and that no configuration file can set,
instead of by the internal_ key prefix. An upstream a local ctrld.toml or
custom config defines as internal_foo, or as a copy of a generated one,
is an ordinary upstream again: it keeps the OS-resolver catch-all,
recovery and query-health grading.

The "resolvers_only" wire value is provisional until the API agrees on
it; the dashboard does not offer strict mode yet.
2026-10-06 14:53:19 +07:00

461 lines
16 KiB
Go

package cli
import (
"context"
"errors"
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/require"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/dnscache"
"github.com/Control-D-Inc/ctrld/testhelper"
)
// wantDNSConfigStrings compares one array field of a logged resolver entry.
func wantDNSConfigStrings(t *testing.T, event map[string]any, field string, want ...string) {
t.Helper()
values, ok := event[field].([]any)
if !ok {
t.Fatalf("field %q: got %T, want an array", field, event[field])
}
if len(values) != len(want) {
t.Fatalf("field %q: got %v, want %v", field, values, want)
}
for i, value := range values {
if value != want[i] {
t.Fatalf("field %q at %d: got %v, want %v", field, i, value, want[i])
}
}
}
// Test_queryHealthCountsFailedProxyQueries drives the real query path against a
// refusing upstream: every sampled error reaches the tracker, so the window of
// the daemon grades as failing.
func Test_queryHealthCountsFailedProxyQueries(t *testing.T) {
captureDebugMainLog(t)
cfg := &ctrld.Config{}
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
p := &prog{cfg: cfg, um: newUpstreamMonitor(cfg), health: newQueryHealth()}
// A new monitor holds back the failure counts for one second, and its
// timer would mark the upstream down after the test.
p.um.clearRecovered(upstreamOS)
p.um.after = func(time.Duration, func()) {}
p.querySampler.onFailure = p.health.countFailure
// The zero value sampler arms a real timer. Close its window after the
// assertions, so no late summary line reaches another test.
t.Cleanup(func() { p.querySampler.closeExpired(time.Now().Add(querySampleWindow)) })
origOSUpstream := osUpstreamConfig
t.Cleanup(func() { osUpstreamConfig = origOSUpstream })
osUpstreamConfig = &ctrld.UpstreamConfig{
Name: "dead resolver",
Type: ctrld.ResolverTypeLegacy,
Endpoint: deadUpstreamEndpoint,
Timeout: 500,
}
const failedQueries = 12
const servedQueries = 20
ctx := context.Background()
for i := 0; i < failedQueries; i++ {
if rcode := askThroughProxy(t, p, ctx).Rcode; rcode != dns.RcodeServerFailure {
t.Fatalf("query %d: rcode %s, want SERVFAIL", i+1, dns.RcodeToString[rcode])
}
}
// serveDNS counts every query it serves, and the eight answered ones never
// reach the sampler.
for i := 0; i < servedQueries; i++ {
p.health.countQuery()
}
event, emit := p.health.evaluate(time.Now(), 0, false)
if !emit {
t.Fatal("the first evaluate did not emit")
}
if event.Class != queryHealthFailing {
t.Fatalf("class: got %q, want %q", event.Class, queryHealthFailing)
}
if event.Queries != servedQueries {
t.Fatalf("queries: got %d, want %d", event.Queries, servedQueries)
}
// The grade follows one failure per failed query. Each of those queries
// also raised two sampler events, and the breakdown still shows them.
if event.FailedQueries != failedQueries {
t.Fatalf("failed queries: got %d, want %d", event.FailedQueries, failedQueries)
}
if got := event.Failures[sampleClassResolveFailed]; got != failedQueries {
t.Fatalf("%s failures: got %d, want %d", sampleClassResolveFailed, got, failedQueries)
}
if got := event.Failures[sampleClassAllEndpointsFailed]; got != failedQueries {
t.Fatalf("%s failures: got %d, want %d", sampleClassAllEndpointsFailed, got, failedQueries)
}
}
// Test_queryHealthKeepsInternalDomainFailuresOutOfTheGrade drives the same
// number of failed queries against an Internal Domain resolver. An endpoint
// away from the organization network cannot reach that resolver, so the class
// stays healthy, the failures keep their own name, and the down entry of that
// resolver stays out of upstreams_down.
func Test_queryHealthKeepsInternalDomainFailuresOutOfTheGrade(t *testing.T) {
captureDebugMainLog(t)
const internalUpstream = upstreamPrefix + internalDomainUpstreamPrefix + "1"
cfg := &ctrld.Config{}
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
cfg.Upstream = map[string]*ctrld.UpstreamConfig{
internalDomainUpstreamPrefix + "1": {
Name: "dead internal resolver",
Type: ctrld.ResolverTypeLegacy,
Endpoint: deadUpstreamEndpoint,
Timeout: 500,
InternalDomain: internalDomainUpstreamOnly,
},
}
p := &prog{cfg: cfg, um: newUpstreamMonitor(cfg), health: newQueryHealth()}
p.um.after = func(time.Duration, func()) {}
p.um.clearRecovered(internalUpstream)
p.querySampler.onFailure = p.health.countFailure
t.Cleanup(func() { p.querySampler.closeExpired(time.Now().Add(querySampleWindow)) })
const failedQueries = 12
const servedQueries = 20
ctx := context.Background()
msg := new(dns.Msg)
msg.SetQuestion("internal.corp.example.", dns.TypeA)
for i := 0; i < failedQueries; i++ {
res := p.proxy(ctx, &proxyRequest{
msg: msg,
ufr: &upstreamForResult{upstreams: []string{internalUpstream}, srcAddr: "192.168.0.1:1234"},
})
if res == nil || res.answer == nil || res.answer.Rcode != dns.RcodeServerFailure {
t.Fatalf("query %d: the internal resolver answered", i+1)
}
}
for i := 0; i < servedQueries; i++ {
p.health.countQuery()
}
p.um.mu.Lock()
p.um.markDown(internalUpstream, 3, "immediate")
p.um.mu.Unlock()
event, emit := p.health.evaluate(time.Now(), p.um.countDownExcept(p.isInternalDomainUpstream), false)
if !emit {
t.Fatal("the first evaluate did not emit")
}
if event.Class != queryHealthHealthy {
t.Fatalf("class: got %q, want %q", event.Class, queryHealthHealthy)
}
if event.FailedQueries != 0 {
t.Fatalf("failed queries: got %d, want 0", event.FailedQueries)
}
if got := event.Failures[sampleClassInternalDomain]; got != failedQueries {
t.Fatalf("%s failures: got %d, want %d", sampleClassInternalDomain, got, failedQueries)
}
if event.UpstreamsDown != 0 {
t.Fatalf("upstreams_down: got %d, want 0", event.UpstreamsDown)
}
if got := p.um.countDown(); got != 1 {
t.Fatalf("countDown: got %d, want 1", got)
}
}
// Test_queryHealthCountsProxyCacheHits proves that an answer from the cache
// counts a cache hit, so the ratio of the journal follows the query path.
func Test_queryHealthCountsProxyCacheHits(t *testing.T) {
captureDebugMainLog(t)
p := &prog{cfg: testhelper.SampleConfig(t), health: newQueryHealth()}
cache, err := dnscache.NewLRUCache(16)
require.NoError(t, err)
p.cache = cache
msg := new(dns.Msg)
msg.SetQuestion("cache.test.", dns.TypeA)
answer := new(dns.Msg)
answer.SetRcode(msg, dns.RcodeSuccess)
p.cache.Add(dnscache.NewKey(msg, "upstream.1"), dnscache.NewValue(answer, time.Now().Add(time.Minute)))
res := p.proxy(context.Background(), &proxyRequest{
msg: msg,
ufr: &upstreamForResult{upstreams: []string{"upstream.1"}},
})
require.NotNil(t, res)
require.True(t, res.cached, "the answer did not come from the cache")
event, _ := p.health.evaluate(time.Now(), 0, false)
if event.CacheHits != 1 {
t.Fatalf("cache hits: got %d, want 1", event.CacheHits)
}
}
// Test_logDNSConfigChangesLogsEveryEntry proves that each changed resolver of
// the host reaches the journal with its fields. A resolver that vanished keeps
// an empty nameserver list.
func Test_logDNSConfigChangesLogsEveryEntry(t *testing.T) {
logs := captureDebugMainLog(t)
p := &prog{}
p.logDNSConfigChanges([]dnsResolverEntry{
{
Order: 200000,
Nameservers: []string{"192.168.50.200", "192.168.50.1"},
IfIndex: 16,
Interface: "en0",
Flags: "Request A records, Request AAAA records",
SearchDomains: []string{"corp.example"},
Reachable: "Reachable,Directly Reachable Address",
},
{Order: 101600, IfIndex: 21, Interface: "utun4", Flags: "Scoped"},
})
events := jsonLogEvents(t, logs, dnsConfigChangedMessage)
if len(events) != 2 {
t.Fatalf("configuration lines: got %d, want 2", len(events))
}
wantField(t, events[0], "journal", true)
wantField(t, events[0], "level", "info")
wantField(t, events[0], "if_index", float64(16))
wantField(t, events[0], "interface", "en0")
wantField(t, events[0], "flags", "Request A records, Request AAAA records")
wantField(t, events[0], "order", float64(200000))
wantField(t, events[0], "reachable", "Reachable,Directly Reachable Address")
wantDNSConfigStrings(t, events[0], "nameservers", "192.168.50.200", "192.168.50.1")
// The suffixes of an organization stay out of the journal. Only their
// number goes in.
wantField(t, events[0], "search_domain_count", float64(1))
if _, ok := events[0]["search_domains"]; ok {
t.Fatal("the journal holds the search domains")
}
wantField(t, events[1], "journal", true)
wantField(t, events[1], "interface", "utun4")
wantDNSConfigStrings(t, events[1], "nameservers")
wantField(t, events[1], "search_domain_count", float64(0))
}
// Test_startNetworkJournalLogsTheStartSnapshot proves that the daemon opens the
// journal with one snapshot and runs the trackers that follow the query path
// and the DNS configuration of the host.
func Test_startNetworkJournalLogsTheStartSnapshot(t *testing.T) {
logs := captureDebugMainLog(t)
stubHeaderSnapshotSources(t)
origTick := queryHealthTick
t.Cleanup(func() { queryHealthTick = origTick })
queryHealthTick = time.Millisecond
var polls atomic.Int64
// The poll loop reads the delay on every pass, so it parks on a long delay
// before the test puts the package value back.
parked := make(chan struct{}, 1)
origRunner, origDelay := runSCUtilDNSFn, dnsConfigDelayFn
t.Cleanup(func() { runSCUtilDNSFn, dnsConfigDelayFn = origRunner, origDelay })
movedResolver := wifiResolverEntry()
movedResolver.Nameservers = []string{"10.0.0.1"}
runSCUtilDNSFn = func() ([]dnsResolverEntry, error) {
if polls.Add(1) == 1 {
return []dnsResolverEntry{wifiResolverEntry()}, nil
}
return []dnsResolverEntry{movedResolver}, nil
}
dnsConfigDelayFn = func(*dnsConfigPoller, time.Time) time.Duration {
if polls.Load() < 2 {
return time.Millisecond
}
select {
case parked <- struct{}{}:
default:
}
return time.Hour
}
cfg := &ctrld.Config{}
p := &prog{cfg: cfg, um: newUpstreamMonitor(cfg), stopCh: make(chan struct{})}
// The monitor arms a real timer for each upstream it resets. Nothing in
// this test waits for one.
p.um.after = func(time.Duration, func()) {}
p.lastNetworkState.Store(sourceTestState("en0", true, "192.0.2.9/24"))
p.recoveryBypass.Store(true)
p.um.mu.Lock()
p.um.markDown(upstreamOS, 3, "timer")
p.um.mu.Unlock()
p.newNetworkJournal()
if p.health == nil || p.querySampler.onFailure == nil {
t.Fatal("the trackers do not exist before the loops start")
}
// A counter of the query path works as soon as the trackers exist.
p.health.countQuery()
waitForJournalLoops := p.startNetworkJournal()
t.Cleanup(func() {
close(p.stopCh)
waitForJournalLoops()
})
events := jsonLogEvents(t, logs, networkSnapshotMessage)
if len(events) != 1 {
t.Fatalf("snapshot lines: got %d, want 1", len(events))
}
wantField(t, events[0], "journal", true)
wantField(t, events[0], "level", "info")
wantField(t, events[0], "trigger", "start")
waitForLogLine(t, logs, queryHealthMessage)
health := jsonLogEvents(t, logs, queryHealthMessage)
wantField(t, health[0], "upstreams_down", float64(1))
wantField(t, health[0], "bypass_active", true)
if runtime.GOOS != "darwin" {
if p.dnsConfig != nil {
t.Fatal("the DNS configuration poller runs outside macOS")
}
return
}
if p.dnsConfig == nil {
t.Fatal("macOS starts no DNS configuration poller")
}
waitForLogLine(t, logs, dnsConfigChangedMessage)
select {
case <-parked:
case <-time.After(5 * time.Second):
t.Fatal("the poll loop did not park")
}
resolvers := jsonLogEvents(t, logs, dnsConfigChangedMessage)
if len(resolvers) != 1 {
t.Fatalf("configuration lines: got %d, want 1", len(resolvers))
}
wantField(t, resolvers[0], "interface", "en0")
wantDNSConfigStrings(t, resolvers[0], "nameservers", "10.0.0.1")
}
// Test_startNetworkJournalReadsTheMonitorAcrossAReload replaces the upstream
// monitor while the health loop runs. A reload builds a new monitor, and the
// loop read the field of the prog with no lock, which the race detector
// reports.
func Test_startNetworkJournalReadsTheMonitorAcrossAReload(t *testing.T) {
captureDebugMainLog(t)
stubHeaderSnapshotSources(t)
origTick := queryHealthTick
t.Cleanup(func() { queryHealthTick = origTick })
queryHealthTick = time.Millisecond
cfg := &ctrld.Config{}
p := &prog{cfg: cfg, stopCh: make(chan struct{})}
p.lastNetworkState.Store(sourceTestState("en0", true, "192.0.2.9/24"))
p.replaceUpstreamMonitor()
p.health = newQueryHealth()
waitForJournalLoops := p.startNetworkJournal()
for i := 0; i < 30; i++ {
p.replaceUpstreamMonitor()
time.Sleep(time.Millisecond)
}
close(p.stopCh)
waitForJournalLoops()
}
// Test_startNetworkJournalRunsAfterAFailedMonitorStart proves that a network
// monitor that cannot start leaves the journal running. The error line goes
// out, the start snapshot opens the journal, and the health loop reports.
func Test_startNetworkJournalRunsAfterAFailedMonitorStart(t *testing.T) {
logs := captureDebugMainLog(t)
stubHeaderSnapshotSources(t)
origTick := queryHealthTick
t.Cleanup(func() { queryHealthTick = origTick })
queryHealthTick = time.Millisecond
origMonitor := newNetworkChangeMonitorFn
t.Cleanup(func() { newNetworkChangeMonitorFn = origMonitor })
newNetworkChangeMonitorFn = func(func(string, ...any)) (networkChangeMonitor, error) {
return nil, errors.New("no network monitor here")
}
origRunner, origDelay := runSCUtilDNSFn, dnsConfigDelayFn
t.Cleanup(func() { runSCUtilDNSFn, dnsConfigDelayFn = origRunner, origDelay })
runSCUtilDNSFn = func() ([]dnsResolverEntry, error) {
return []dnsResolverEntry{wifiResolverEntry()}, nil
}
dnsConfigDelayFn = func(*dnsConfigPoller, time.Time) time.Duration { return time.Hour }
cfg := &ctrld.Config{}
p := &prog{cfg: cfg, stopCh: make(chan struct{})}
p.lastNetworkState.Store(sourceTestState("en0", true, "192.0.2.9/24"))
p.replaceUpstreamMonitor()
p.upstreamMonitorNow().after = func(time.Duration, func()) {}
p.newNetworkJournal()
p.health.countQuery()
p.health.mu.Lock()
queries := p.health.queries
p.health.mu.Unlock()
if queries != 1 {
t.Fatalf("queries after one count: got %d, want 1", queries)
}
waitForJournalLoops := p.startNetworkMonitorAndJournal()
t.Cleanup(func() {
close(p.stopCh)
waitForJournalLoops()
})
failures := jsonLogEvents(t, logs, "Failed to start network monitoring")
if len(failures) != 1 {
t.Fatalf("monitor failure lines: got %d, want 1", len(failures))
}
wantField(t, failures[0], "level", "error")
snapshots := jsonLogEvents(t, logs, networkSnapshotMessage)
if len(snapshots) != 1 {
t.Fatalf("snapshot lines: got %d, want 1", len(snapshots))
}
wantField(t, snapshots[0], "trigger", "start")
waitForLogLine(t, logs, queryHealthMessage)
}
// Test_queryHealthCountsNoFailureWhenAnUpstreamAnswers proves that a failure on
// one upstream does not grade the query. The client got an answer, so the
// failure stays in the breakdown only. The DNS64 companion lookup fails through
// the same call, so it cannot grade a query either.
func Test_queryHealthCountsNoFailureWhenAnUpstreamAnswers(t *testing.T) {
captureDebugMainLog(t)
fixture := startDNSFixture(t)
cfg := &ctrld.Config{}
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
cfg.Upstream = map[string]*ctrld.UpstreamConfig{
"0": {Name: "dead resolver", Type: ctrld.ResolverTypeLegacy, Endpoint: deadUpstreamEndpoint, Timeout: 500},
"1": {Name: "live resolver", Type: ctrld.ResolverTypeLegacy, Endpoint: fixture.addr, Timeout: 2000},
}
p := &prog{cfg: cfg, um: newUpstreamMonitor(cfg), health: newQueryHealth()}
p.um.after = func(time.Duration, func()) {}
p.um.clearRecovered(upstreamPrefix + "0")
p.querySampler.onFailure = p.health.countFailure
t.Cleanup(func() { p.querySampler.closeExpired(time.Now().Add(querySampleWindow)) })
msg := new(dns.Msg)
msg.SetQuestion("failover.test.", dns.TypeA)
res := p.proxy(context.Background(), &proxyRequest{
msg: msg,
ufr: &upstreamForResult{
upstreams: []string{upstreamPrefix + "0", upstreamPrefix + "1"},
srcAddr: "192.168.0.1:1234",
},
})
if res == nil || res.answer == nil || res.answer.Rcode != dns.RcodeSuccess {
t.Fatal("the second upstream did not answer")
}
p.health.countQuery()
event, _ := p.health.evaluate(time.Now(), 0, false)
if event.FailedQueries != 0 {
t.Fatalf("failed queries: got %d, want 0", event.FailedQueries)
}
if got := event.Failures[sampleClassResolveFailed]; got != 1 {
t.Fatalf("%s failures: got %d, want 1", sampleClassResolveFailed, got)
}
if event.Class != queryHealthHealthy {
t.Fatalf("class: got %q, want %q", event.Class, queryHealthHealthy)
}
}