cli: route organization Internal Domains from managed config

Port the final merged change from !1009 to master, retaining master's
context-aware logger/resolver APIs, proxy decomposition and firewall
refresh handling. Adapt source regression tests and cover master's
refresh handler and transport-level private-address diagnostics.
This commit is contained in:
Cuong Manh Le committed 2026-09-18 13:37:27 +07:00
1 parent 13471ba128
commit 25b79d5e73
12 files changed
+2248 -18

No files matched your search

+59
View File
@@ -29,6 +29,65 @@ func refresh(t *testing.T, p *prog, forced bool, rc *controld.ResolverConfig) {
p.applyFetchedResolverConfig(context.Background(), discardLogger(), rc, forced, time.Now().Unix())
}
// TestRefreshInternalDomains covers the master-specific refresh handler after
// apiConfigReload delegates to it, including changes with unchanged excludes.
func TestRefreshInternalDomains(t *testing.T) {
for _, forced := range []bool{false, true} {
name := "scheduled"
if forced {
name = "forced"
}
t.Run(name, func(t *testing.T) {
// No platform firewall or service is installed by this fixture.
p := &prog{rc: &controld.ResolverConfig{}, apiReloadCh: make(chan *ctrld.Config, 1)}
p.logger.Store(discardLogger())
for _, step := range []struct {
name string
entries []controld.SplitDNS
reload bool
}{
{"absent", nil, false},
{"add", []controld.SplitDNS{{Domain: "corp.example", Mode: "os"}}, true},
{"unchanged", []controld.SplitDNS{{Domain: "CORP.EXAMPLE.", Mode: "os"}}, false},
{"mode", []controld.SplitDNS{{Domain: "corp.example", Mode: "resolvers", Resolvers: []string{"127.0.0.2"}}}, true},
{"resolver", []controld.SplitDNS{{Domain: "corp.example", Mode: "resolvers", Resolvers: []string{"127.0.0.3"}}}, true},
{"domain", []controld.SplitDNS{{Domain: "office.example", Mode: "resolvers", Resolvers: []string{"127.0.0.3"}}}, true},
{"remove", nil, true},
{"empty unchanged", []controld.SplitDNS{}, false},
} {
t.Run(step.name, func(t *testing.T) {
rc := &controld.ResolverConfig{SplitDNS: step.entries}
refresh(t, p, forced, rc)
gotReload := false
select {
case cfg := <-p.apiReloadCh:
gotReload = true
if cfg != nil {
t.Fatal("managed change must request config regeneration")
}
default:
}
if gotReload != step.reload {
t.Fatalf("reload = %v, want %v", gotReload, step.reload)
}
if p.rc != rc {
t.Fatal("refresh did not retain the authoritative response")
}
})
}
// Changes to Internal Domains must not override a custom config.
rc := &controld.ResolverConfig{SplitDNS: []controld.SplitDNS{{Domain: "corp.example", Mode: "os"}}}
rc.Ctrld.CustomConfig = "custom-config-is-authoritative"
refresh(t, p, false, rc)
select {
case <-p.apiReloadCh:
t.Fatal("Internal Domains overrode unchanged custom config")
default:
}
})
}
}
// TestRefreshAppliesAllowedDestinations drives the real refresh handler for both
// the scheduled and the forced path, in the case where nothing else about the
// configuration changed - no custom config, unchanged exclusions - so the
+25
View File
@@ -1111,6 +1111,14 @@ func processCDFlags(ctx context.Context, cfg *ctrld.Config) (*controld.ResolverC
}
cfg.Listener["0"] = lc
// Organization Internal Domains. Applied after the Magic Folder excludes
// above, so an excluded domain keeps its exclusion, and before the
// platform AD auto-detection in addExtraSplitDnsRule, which skips any
// domain that already has a rule.
if summary := applyInternalDomains(cfg, resolverConfig.SplitDNS); !summary.empty() {
logInternalDomainsSummary(logger, summary)
}
// Set default value.
setListenerDefaultValue(cfg)
setNetworkDefaultValue(cfg)
@@ -1118,6 +1126,23 @@ func processCDFlags(ctx context.Context, cfg *ctrld.Config) (*controld.ResolverC
return resolverConfig, nil
}
// logInternalDomainsSummary reports what the Internal Domains list produced,
// as counts only. Domain names and resolver addresses are private to the
// organization and stay at debug level.
func logInternalDomainsSummary(logger *ctrld.Logger, summary internalDomainsSummary) {
event := logger.Info()
if summary.skipped > 0 || summary.preempted > 0 {
event = logger.Warn()
}
event.Int("domains", summary.domains).
Int("os_resolver", summary.osMode).
Int("explicit_resolver", summary.explicit).
Int("resolvers", summary.resolvers).
Int("skipped", summary.skipped).
Int("preempted", summary.preempted).
Msg("applied organization Internal Domains")
}
// setListenerDefaultValue sets the default value for cfg.Listener if none existed.
func setListenerDefaultValue(cfg *ctrld.Config) {
if len(cfg.Listener) == 0 {
+28 -7
View File
@@ -739,7 +739,9 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
// DNS intercept recovery bypass: forward all queries to OS/DHCP resolver.
// This runs when upstreams are unreachable (e.g., captive portal network)
// and allows the network's DNS to handle authentication pages.
if dnsIntercept && p.recoveryBypass.Load() {
// Explicit Internal Domain resolvers remain exclusive during recovery:
// general DNS failure does not authorize disclosing private names to DHCP DNS.
if dnsIntercept && p.recoveryBypass.Load() && !internalDomainExplicitUpstreams(req.ufr.upstreams) {
ctrld.Log(ctx, p.Debug(), "Recovery bypass active: forwarding to OS resolver")
answer := p.queryUpstream(ctx, req, upstreamOS, osUpstreamConfig)
if answer != nil {
@@ -762,8 +764,10 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
return cachedRes
}
// VPN DNS split routing (only in dns-intercept mode)
if dnsIntercept && p.vpnDNS != nil && len(req.msg.Question) > 0 {
// VPN DNS split routing (only in dns-intercept mode). Explicit Internal
// Domain resolvers take precedence over auto-detected VPN routes. OS-mode
// Internal Domains still follow the endpoint's VPN resolver.
if dnsIntercept && p.vpnDNS != nil && len(req.msg.Question) > 0 && !internalDomainExplicitUpstreams(upstreams) {
domain := req.msg.Question[0].Name
if vpnServers := p.vpnDNS.UpstreamForDomain(domain); len(vpnServers) > 0 {
ctrld.Log(ctx, p.Debug(), "VPN DNS route matched for domain %s, using servers: %v", domain, vpnServers)
@@ -969,7 +973,11 @@ func (p *prog) serveStaleResponse(ctx context.Context, staleAnswer *dns.Msg) *pr
func (p *prog) handleAllUpstreamsFailure(ctx context.Context, req *proxyRequest, upstreams []string) *proxyResponse {
ctrld.Log(ctx, p.Error(), "All %v endpoints failed", upstreams)
if p.leakOnUpstreamFailure() {
// An unavailable internal resolver must not trigger endpoint-wide recovery
// or send the private name through the OS-resolver catch-all.
if internalDomainExplicitUpstreams(upstreams) {
ctrld.Log(ctx, p.Debug(), "Internal domain resolvers unreachable; not falling back")
} else if p.leakOnUpstreamFailure() {
ctrld.Log(ctx, p.Debug(), "Leak on upstream failure enabled")
if p.um.countHealthy(upstreams) == 0 {
ctrld.Log(ctx, p.Debug(), "No healthy upstreams, triggering recovery")
@@ -1146,6 +1154,9 @@ func (p *prog) processUpstream(ctx context.Context, req *proxyRequest, upstream
// queryUpstream sends a DNS query to a specified upstream using its configuration and handles errors and retries.
func (p *prog) queryUpstream(ctx context.Context, req *proxyRequest, upstream string, upstreamConfig *ctrld.UpstreamConfig) *dns.Msg {
if isInternalDomainUpstream(upstream) {
ctx = ctrld.PrivateResolverCtx(ctx)
}
if upstreamConfig.UpstreamSendClientInfo() && req.ci != nil {
ctrld.Log(ctx, p.Debug(), "Adding client info to upstream query")
ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, req.ci)
@@ -1172,7 +1183,15 @@ func (p *prog) queryUpstream(ctx context.Context, req *proxyRequest, upstream st
return answer
}
ctrld.Log(ctx, p.Error().Err(err), "Failed to resolve query")
// Transport errors contain the organization's private resolver address.
// Keep that detail at debug while retaining a visible failure classification.
if isInternalDomainUpstream(upstream) {
ctrld.Log(ctx, p.Debug().Err(err), "Failed to resolve query")
ctrld.Log(ctx, p.Error().Str("failure", internalDomainFailureReason(err)),
"Failed to resolve query using an Internal Domain resolver")
} else {
ctrld.Log(ctx, p.Error().Err(err), "Failed to resolve query")
}
// Increasing the failure count when there is no answer regardless of what kind of error we get
p.um.increaseFailureCount(upstream)
if err != nil {
@@ -2235,7 +2254,8 @@ func (p *prog) checkUpstreamOnce(upstream string, uc *ctrld.UpstreamConfig) erro
resolver, err := ctrld.NewResolver(ctrld.LoggerCtx(context.Background(), p.logger.Load()), uc)
if err != nil {
p.Error().Err(err).Msgf("Failed to create resolver for upstream %s", upstream)
p.logUpstreamProbeFailure(isGeneratedInternalDomainUpstreamRef(upstream, uc), uc, err,
p.Error, "Failed to create resolver for upstream %s", upstream)
return err
}
@@ -2274,7 +2294,8 @@ func (p *prog) checkUpstreamOnce(upstream string, uc *ctrld.UpstreamConfig) erro
p.Debug().Err(err).Msgf("Upstream %s check failed after %v (network unreachable)", upstream, duration)
return err
}
p.Error().Err(err).Msgf("Upstream %s check failed after %v", upstream, duration)
p.logUpstreamProbeFailure(isGeneratedInternalDomainUpstreamRef(upstream, uc), uc, err,
p.Error, "Upstream %s check failed after %v", upstream, duration)
return err
}
p.Debug().Msgf("Upstream %s responded successfully in %v", upstream, duration)
+454
View File
@@ -0,0 +1,454 @@
package cli
import (
"context"
"errors"
"net"
"net/netip"
"slices"
"sort"
"strconv"
"strings"
"syscall"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
ctrldnet "github.com/Control-D-Inc/ctrld/internal/net"
)
// internalDomainUpstreamPrefix names the upstreams generated for organization
// Internal Domains that select explicit resolvers. The prefix is what tells
// proxy() that a rule is served only by configured resolvers, so it
// must not collide with the numeric keys used for the Control D upstream.
const internalDomainUpstreamPrefix = "internal_"
// internalDomainUpstreamName is the Name given to every generated Internal
// Domain upstream. Together with the key prefix and the legacy type it is what
// distinguishes an upstream ctrld generated from one an endpoint custom
// configuration happens to name the same way.
const internalDomainUpstreamName = "Internal Domain resolver"
// internalDomainsSummary counts the outcome of applying Internal Domains. It
// carries no domain names and no resolver addresses: info and warning logs
// report these counts, and the values themselves stay at debug level.
type internalDomainsSummary struct {
domains int // domains that produced rules
osMode int // domains routed to the OS/default resolver
explicit int // domains routed to configured resolvers
resolvers int // explicit resolver upstreams generated
skipped int // entries dropped: unusable domain, no usable resolver, or duplicate
preempted int // domains already covered by a higher precedence rule
}
// empty reports whether nothing was applied and nothing was dropped, which is
// the shape of a config whose split_dns list was absent or empty.
func (s internalDomainsSummary) empty() bool {
return s.domains == 0 && s.skipped == 0 && s.preempted == 0
}
// internalDomainLabelOK reports whether label satisfies the ASCII hostname
// contract the API validates against: 1-63 characters, letters, digits and
// hyphens only, and no leading or trailing hyphen. Punycode labels are plain
// LDH and pass unchanged.
func internalDomainLabelOK(label string) bool {
if len(label) == 0 || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
default:
return false
}
}
return true
}
// normalizeInternalDomain canonicalizes a domain to the form policy rules are
// matched in, or returns "" if it is not a domain ctrld will route.
//
// Canonicalization is limited to what cannot change which names match:
// lowercasing, surrounding whitespace, the root dot, and a leading "*." (the
// caller adds the wildcard rule itself). What remains is validated as an ASCII
// hostname suffix rather than screened against a list of bad characters,
// because a denylist admits whatever it forgot: an embedded newline, quote,
// colon or control character would otherwise reach a generated rule.
//
// An invalid entry is rejected rather than repaired. Deleting the offending
// characters would produce a different suffix from the one that was
// configured, and routing a private domain to the wrong place is worse than
// not routing it. Single-label names stay supported.
//
// The rule matches the API's own validation, so a value the dashboard accepted
// passes here too.
func normalizeInternalDomain(domain string) string {
d := strings.ToLower(strings.TrimSpace(domain))
d = strings.TrimPrefix(d, "*.")
d = strings.TrimSuffix(d, ".")
if d == "" || len(d) > 253 {
return ""
}
for _, label := range strings.Split(d, ".") {
if !internalDomainLabelOK(label) {
return ""
}
}
return d
}
// internalDomainEndpoint converts a resolver to an endpoint ctrld can dial.
// A bare IPv4 or IPv6 address gets the default DNS port; and address that
// already carries a port keeps it. Anything else is rejected, so a malformed
// entry cannot silently become a hostname lookup.
func internalDomainEndpoint(resolver string) (string, bool) {
s := strings.TrimSpace(resolver)
if s == "" {
return "", false
}
if addr, err := netip.ParseAddr(s); err == nil {
return net.JoinHostPort(addr.String(), "53"), true
}
host, port, err := net.SplitHostPort(s)
if err != nil {
return "", false
}
addr, err := netip.ParseAddr(host)
if err != nil {
return "", false
}
p, err := strconv.Atoi(port)
if err != nil || p < 1 || p > 65535 {
return "", false
}
return net.JoinHostPort(addr.String(), port), true
}
// internalDomainMode reports whether entry selects explicit resolvers, and
// whether ctrld understands its mode at all.
//
// Mode is authoritative when the API sends it. "os" ignores any resolver
// addresses the entry still carries, so switching a domain back to the OS
// resolver cannot be undone by addresses a previous selection left behind;
// "resolvers" stays explicit even when the list it points at turns out to be
// unusable, rather than silently becoming OS resolution. An empty mode is a
// deployment that predates the field, where the list is the only signal.
//
// An unrecognized mode is not guessed at: routing a private domain by guess is
// the disclosure this feature exists to prevent.
func internalDomainMode(entry controld.SplitDNS) (explicit, known bool) {
switch strings.ToLower(strings.TrimSpace(entry.Mode)) {
case controld.SplitDNSModeOS:
return false, true
case controld.SplitDNSModeResolvers:
return true, true
case "":
return len(entry.Resolvers) > 0, true
default:
return false, false
}
}
// internalDomainRouting returns the endpoints an entry routes to, or a reason
// it cannot be used. No endpoints and no reason means OS resolution.
//
// Generation and refresh comparison both go through here, so the two can never
// disagree about what an entry means.
func internalDomainRouting(entry controld.SplitDNS) (endpoints []string, reason string) {
explicit, known := internalDomainMode(entry)
if !known {
return nil, "unknown_mode"
}
if !explicit {
return nil, ""
}
endpoints = make([]string, 0, len(entry.Resolvers))
for _, resolver := range entry.Resolvers {
if endpoint, ok := internalDomainEndpoint(resolver); ok {
endpoints = append(endpoints, endpoint)
}
}
// The administrator selected explicit resolvers and none of them parsed.
// Falling back to the OS resolver would turn that selection into a
// different one, so the entry is dropped and the domain keeps its normal
// path instead.
if len(endpoints) == 0 {
return nil, "no_usable_resolver"
}
return endpoints, ""
}
// internalDomainRuleExists reports whether the listener policy already routes
// source. Internal Domains never overwrite a rule that is already there, which
// is what gives Magic Folder excludes and endpoint custom configuration
// precedence over them.
func internalDomainRuleExists(lc *ctrld.ListenerConfig, source string) bool {
if lc.Policy == nil {
return false
}
for _, rule := range lc.Policy.Rules {
if _, ok := rule[source]; ok {
return true
}
}
return false
}
// internalDomainSpecificity ranks a normalized domain by label count, which is
// how much of a name it pins down. "z.example.com" ranks above "example.com".
func internalDomainSpecificity(domain string) int {
if domain == "" {
return 0
}
return strings.Count(domain, ".") + 1
}
// orderInternalDomainsBySpecificity returns entries most-specific first.
//
// upstreamFor matches policy rules in slice order and takes the first hit, so
// generation order is routing precedence. The API sorts Internal Domains
// bytewise by domain, which puts a parent suffix ahead of its children:
// appending in that order would let "*.example.com" swallow every query a
// configured "z.example.com" rule was meant to answer, and the administrator
// never chose that priority. Overlapping suffixes are permitted, so the more
// specific one has to win.
//
// The sort is stable, so entries that are equally specific keep API order and
// the first of two identical domains still wins the duplicate check.
func orderInternalDomainsBySpecificity(entries []controld.SplitDNS) []controld.SplitDNS {
ordered := make([]controld.SplitDNS, len(entries))
copy(ordered, entries)
sort.SliceStable(ordered, func(i, j int) bool {
a, b := normalizeInternalDomain(ordered[i].Domain), normalizeInternalDomain(ordered[j].Domain)
if sa, sb := internalDomainSpecificity(a), internalDomainSpecificity(b); sa != sb {
return sa > sb
}
return a < b
})
return ordered
}
// applyInternalDomains adds a policy rule per organization Internal Domain to
// every listener in cfg, and the upstreams those rules need.
//
// Each domain contributes two rules, the domain itself and "*." + domain, which
// is how a suffix and all of its subdomains are matched. An entry with no
// resolvers routes to the OS/default resolver by way of an empty upstream list,
// the same representation Magic Folder excludes use. An entry with resolvers
// gets one generated upstream per address, listed in configured order so the
// later ones act as failover for the first.
//
// cfg is rebuilt from the API response on every fetch, so this function is the
// only writer of internal_* upstreams: a removed domain leaves nothing behind.
func applyInternalDomains(cfg *ctrld.Config, entries []controld.SplitDNS) internalDomainsSummary {
var summary internalDomainsSummary
if len(entries) == 0 || len(cfg.Listener) == 0 {
return summary
}
if cfg.Upstream == nil {
cfg.Upstream = make(map[string]*ctrld.UpstreamConfig)
}
seen := make(map[string]struct{}, len(entries))
for _, entry := range orderInternalDomainsBySpecificity(entries) {
domain := normalizeInternalDomain(entry.Domain)
if domain == "" {
mainLog.Load().Debug().Msgf("internal domains: skipping unusable domain %q", entry.Domain)
summary.skipped++
continue
}
if _, dup := seen[domain]; dup {
mainLog.Load().Debug().Msgf("internal domains: skipping duplicate domain %q", domain)
summary.skipped++
continue
}
// Routing is resolved, and preemption decided, before anything is
// written: an entry that cannot be used, or a domain already covered by
// a higher precedence rule, must not leave a generated upstream behind
// with no rule referring to it.
endpoints, reason := internalDomainRouting(entry)
if reason != "" {
mainLog.Load().Debug().Msgf("internal domains: skipping %q: %s", domain, reason)
summary.skipped++
continue
}
seen[domain] = struct{}{}
sources := []string{domain, "*." + domain}
free := make(map[*ctrld.ListenerConfig][]string, len(cfg.Listener))
anyFree := false
for _, lc := range cfg.Listener {
for _, source := range sources {
if internalDomainRuleExists(lc, source) {
mainLog.Load().Debug().Msgf("internal domains: %q already routed by a higher precedence rule", source)
continue
}
free[lc] = append(free[lc], source)
anyFree = true
}
}
if !anyFree {
summary.preempted++
continue
}
targets := make([]string, 0, len(endpoints))
for _, endpoint := range endpoints {
key := internalDomainUpstreamPrefix + strconv.Itoa(summary.resolvers)
cfg.Upstream[key] = &ctrld.UpstreamConfig{
Name: internalDomainUpstreamName,
Type: ctrld.ResolverTypeLegacy,
Endpoint: endpoint,
Timeout: 5000,
}
targets = append(targets, upstreamPrefix+key)
summary.resolvers++
}
for lc, sources := range free {
if lc.Policy == nil {
lc.Policy = &ctrld.ListenerPolicyConfig{}
}
for _, source := range sources {
lc.Policy.Rules = append(lc.Policy.Rules, ctrld.Rule{source: targets})
}
}
summary.domains++
if len(targets) == 0 {
summary.osMode++
mainLog.Load().Debug().Msgf("internal domains: %q routed to the OS resolver", domain)
} else {
summary.explicit++
mainLog.Load().Debug().Msgf("internal domains: %q routed to %v", domain, targets)
}
}
return summary
}
// isGeneratedInternalDomainUpstream reports whether a cfg.Upstream entry is one
// applyInternalDomains created.
//
// Generated Internal Domain resolvers are part of the managed Control D
// configuration, not upstreams the endpoint operator chose, so eligibility
// checks that ask "is this a plain single-upstream Control D install" must not
// count them. The key prefix alone is not proof of that, because an endpoint
// custom configuration replaces the generated config outright and could name an
// upstream anything; the shape this function checks is only produced here.
func isGeneratedInternalDomainUpstream(name string, uc *ctrld.UpstreamConfig) bool {
return uc != nil &&
strings.HasPrefix(name, internalDomainUpstreamPrefix) &&
uc.Type == ctrld.ResolverTypeLegacy &&
uc.Name == internalDomainUpstreamName
}
// isInternalDomainUpstream reports whether an upstream reference was generated
// for an Internal Domain with explicit resolvers.
func isInternalDomainUpstream(upstream string) bool {
return strings.HasPrefix(upstream, upstreamPrefix+internalDomainUpstreamPrefix)
}
// internalDomainExplicitUpstreams reports whether every upstream serving a
// query is an configured Internal Domain resolver.
//
// proxy() uses this to keep such a query off the OS-resolver catch-all when the
// configured resolvers are unreachable: the administrator selected those
// resolvers and no other, so a private name must fail rather than be sent
// somewhere it was not meant to go. It also keeps an unreachable internal
// resolver from triggering the endpoint-wide recovery flow, which exists for
// the loss of general DNS, not for one unavailable internal server.
func internalDomainExplicitUpstreams(upstreams []string) bool {
if len(upstreams) == 0 {
return false
}
for _, upstream := range upstreams {
if !isInternalDomainUpstream(upstream) {
return false
}
}
return true
}
// internalDomainsEqual reports whether two Internal Domains lists would produce
// the same routing. Domains are compared in canonical form and independently of
// the order the API returned them, so an unchanged list does not trigger a
// reload on every refresh. Resolver order is significant: it is the order the
// administrator chose, and it decides which resolver is tried first.
func internalDomainsEqual(a, b []controld.SplitDNS) bool {
canonical := func(entries []controld.SplitDNS) []controld.SplitDNS {
out := make([]controld.SplitDNS, 0, len(entries))
for _, entry := range entries {
domain := normalizeInternalDomain(entry.Domain)
if domain == "" {
continue
}
// Entries that cannot be used route nothing, so they cannot make
// an otherwise unchanged list look changed.
endpoints, reason := internalDomainRouting(entry)
if reason != "" {
continue
}
out = append(out, controld.SplitDNS{Domain: domain, Resolvers: endpoints})
}
sort.Slice(out, func(i, j int) bool { return out[i].Domain < out[j].Domain })
return out
}
x, y := canonical(a), canonical(b)
if len(x) != len(y) {
return false
}
for i := range x {
if x[i].Domain != y[i].Domain || !slices.Equal(x[i].Resolvers, y[i].Resolvers) {
return false
}
}
return true
}
// internalDomainFailureReason classifies a resolver failure for logging. The
// error itself names the endpoint that could not be reached, so only this
// classification may appear above debug level.
func internalDomainFailureReason(err error) string {
switch {
case err == nil:
return "no_answer"
case errors.Is(err, syscall.ECONNREFUSED):
return "refused"
case errors.Is(err, syscall.EADDRNOTAVAIL):
return "source_unavailable"
case ctrldnet.IsUnreachable(err):
return "unreachable"
}
var netErr net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
return "timeout"
}
return "error"
}
// isGeneratedInternalDomainUpstreamRef is isGeneratedInternalDomainUpstream for
// call sites that hold an "upstream.<key>" reference rather than the bare key.
func isGeneratedInternalDomainUpstreamRef(upstream string, uc *ctrld.UpstreamConfig) bool {
return isGeneratedInternalDomainUpstream(strings.TrimPrefix(upstream, upstreamPrefix), uc)
}
// logUpstreamProbeFailure reports a background probe failure against an
// upstream.
//
// Loop checking and recovery checking run on timers, so for a generated
// Internal Domain resolver they would publish the organization's private
// address without any user ever querying the domain. They get the same
// treatment as a query failure: endpoint and the address-bearing error at
// debug, a classification above it, so the outcome stays visible while the
// address does not.
func (p *prog) logUpstreamProbeFailure(generated bool, uc *ctrld.UpstreamConfig, err error, level func() *ctrld.LogEvent, format string, args ...any) {
detail := append([]any{}, args...)
if !generated {
level().Err(err).Msgf(format+" for upstream: %q, endpoint: %q", append(detail, uc.Name, uc.Endpoint)...)
return
}
p.Debug().Err(err).Msgf(format+" for upstream: %q, endpoint: %q", append(detail, uc.Name, uc.Endpoint)...)
level().Str("failure", internalDomainFailureReason(err)).
Msgf(format+" for an Internal Domain resolver", detail...)
}
+642
View File
@@ -0,0 +1,642 @@
package cli
import (
"context"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// dnsFixture is a loopback DNS server that records the questions it is asked,
// so a test can prove where a query actually went on the wire rather than
// inferring it from an rcode.
type dnsFixture struct {
addr string
mu sync.Mutex
qs []string
}
func (f *dnsFixture) questions() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.qs...)
}
func startDNSFixture(t *testing.T) *dnsFixture {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f := &dnsFixture{addr: pc.LocalAddr().String()}
started := make(chan struct{})
server := &dns.Server{
PacketConn: pc,
NotifyStartedFunc: func() { close(started) },
Handler: dns.HandlerFunc(func(w dns.ResponseWriter, msg *dns.Msg) {
f.mu.Lock()
if len(msg.Question) > 0 {
f.qs = append(f.qs, msg.Question[0].Name)
}
f.mu.Unlock()
reply := new(dns.Msg)
reply.SetRcode(msg, dns.RcodeSuccess)
_ = w.WriteMsg(reply)
}),
}
go func() { _ = server.ActivateAndServe() }()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("DNS fixture did not start")
}
t.Cleanup(func() { _ = server.Shutdown() })
return f
}
// useOSResolverFixture points the OS-resolver boundary at a loopback server for
// the duration of the test, so "went to the OS resolver" is observable.
func useOSResolverFixture(t *testing.T, f *dnsFixture) {
t.Helper()
prev := osUpstreamConfig
osUpstreamConfig = &ctrld.UpstreamConfig{
Name: "OS resolver",
Type: ctrld.ResolverTypeLegacy,
Endpoint: f.addr,
Timeout: 2000,
}
t.Cleanup(func() { osUpstreamConfig = prev })
}
func useDNSIntercept(t *testing.T) {
t.Helper()
prev := dnsIntercept
dnsIntercept = true
t.Cleanup(func() { dnsIntercept = prev })
}
func newInternalDomainsProg(t *testing.T, cfg *ctrld.Config) *prog {
t.Helper()
p := &prog{cfg: cfg}
p.logger.Store(mainLog.Load())
p.um = newUpstreamMonitor(cfg, p.logger.Load())
p.lanLoopGuard = newLoopGuard()
p.ptrLoopGuard = newLoopGuard()
return p
}
// The DNS-intercept recovery bypass forwards everything to the OS/DHCP resolver
// while general DNS is broken. An Internal Domain with explicit resolvers must
// not take part: its resolvers may be perfectly healthy, and the private name
// must not reach the network's DNS.
func TestInternalDomainsExplicitResolverSurvivesRecoveryBypass(t *testing.T) {
osFixture := startDNSFixture(t)
internalFixture := startDNSFixture(t)
useOSResolverFixture(t, osFixture)
useDNSIntercept(t)
cfg := internalDomainsTestConfig()
applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "corp.example", Resolvers: []string{internalFixture.addr}},
})
p := newInternalDomainsProg(t, cfg)
p.recoveryBypass.Store(true)
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
if err != nil {
t.Fatal(err)
}
ask := func(name string) *proxyResponse {
t.Helper()
ctx := context.WithValue(ctrld.LoggerCtx(context.Background(), p.logger.Load()), ctrld.ReqIdCtxKey{}, requestID())
ufr := p.upstreamFor(ctx, "0", cfg.Listener["0"], addr, "", strings.TrimSuffix(name, "."))
return p.proxy(ctx, &proxyRequest{msg: newDnsMsgWithHostname(name, dns.TypeA), ufr: ufr})
}
res := ask("host.corp.example.")
if got := internalFixture.questions(); len(got) != 1 || got[0] != "host.corp.example." {
t.Errorf("internal resolver questions = %v, want the query to reach it", got)
}
if got := osFixture.questions(); len(got) != 0 {
t.Errorf("recovery bypass leaked an Internal Domain query to the OS resolver: %v", got)
}
if res == nil || res.answer == nil || res.answer.Rcode != dns.RcodeSuccess {
t.Errorf("answer = %v, want the internal resolver's NOERROR", res)
}
// Control: an ordinary query still takes the bypass.
if res := ask("www.example.net."); res == nil || res.answer == nil {
t.Errorf("ordinary query during recovery bypass = %v", res)
}
if got := osFixture.questions(); len(got) != 1 || got[0] != "www.example.net." {
t.Errorf("OS resolver questions = %v, want the ordinary query to take the bypass", got)
}
}
// When the configured resolvers are unreachable the query must fail rather than
// reach the OS resolver, and the failure must be observable on the wire as an
// attempt against the configured address.
func TestInternalDomainsUnreachableResolverDoesNotReachOSResolver(t *testing.T) {
osFixture := startDNSFixture(t)
useOSResolverFixture(t, osFixture)
useDNSIntercept(t)
// A loopback port nothing listens on: the dial fails fast and locally.
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
dead := pc.LocalAddr().String()
_ = pc.Close()
cfg := internalDomainsTestConfig()
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(true)
applyInternalDomains(cfg, []controld.SplitDNS{{Domain: "corp.example", Resolvers: []string{dead}}})
p := newInternalDomainsProg(t, cfg)
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx := context.WithValue(ctrld.LoggerCtx(context.Background(), p.logger.Load()), ctrld.ReqIdCtxKey{}, requestID())
ufr := p.upstreamFor(ctx, "0", cfg.Listener["0"], addr, "", "host.corp.example")
res := p.proxy(ctx, &proxyRequest{msg: newDnsMsgWithHostname("host.corp.example.", dns.TypeA), ufr: ufr})
if got := osFixture.questions(); len(got) != 0 {
t.Errorf("unreachable internal resolver leaked to the OS resolver: %v", got)
}
if res == nil || res.answer == nil || res.answer.Rcode != dns.RcodeServerFailure {
t.Errorf("answer = %v, want SERVFAIL", res)
}
}
// The API returns Internal Domains sorted bytewise by domain, which puts a
// parent suffix ahead of its children. Generation order is routing precedence,
// so the more specific suffix has to win regardless of API order.
func TestInternalDomainsOverlappingSuffixPrecedence(t *testing.T) {
for _, tc := range []struct {
name string
entries []controld.SplitDNS
want map[string][]string
}{
{
name: "parent os, child explicit",
entries: []controld.SplitDNS{
{Domain: "example.com"},
{Domain: "z.example.com", Resolvers: []string{"10.0.0.2"}},
},
want: map[string][]string{
"z.example.com": {"upstream.internal_0"},
"host.z.example.com": {"upstream.internal_0"},
"a.b.z.example.com": {"upstream.internal_0"},
"example.com": {},
"other.example.com": {},
"notz.example.com": {},
"z.example.com.evil.gg": {"upstream.0"},
"example.org": {"upstream.0"},
},
},
{
name: "parent explicit, child os",
entries: []controld.SplitDNS{
{Domain: "example.com", Resolvers: []string{"10.0.0.2"}},
{Domain: "z.example.com"},
},
want: map[string][]string{
"z.example.com": {},
"host.z.example.com": {},
"example.com": {"upstream.internal_0"},
"other.example.com": {"upstream.internal_0"},
"example.org": {"upstream.0"},
},
},
{
name: "three levels, API order",
entries: []controld.SplitDNS{
{Domain: "example.com", Resolvers: []string{"10.0.0.1"}},
{Domain: "y.example.com"},
{Domain: "x.y.example.com", Resolvers: []string{"10.0.0.3"}},
},
want: map[string][]string{
"host.x.y.example.com": {"upstream.internal_0"},
"x.y.example.com": {"upstream.internal_0"},
"other.y.example.com": {},
"y.example.com": {},
"other.example.com": {"upstream.internal_1"},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := internalDomainsTestConfig()
applyInternalDomains(cfg, tc.entries)
p := newInternalDomainsProg(t, cfg)
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
if err != nil {
t.Fatal(err)
}
for domain, want := range tc.want {
ufr := p.upstreamFor(context.Background(), "0", cfg.Listener["0"], addr, "", domain)
if strings.Join(ufr.upstreams, ",") != strings.Join(want, ",") {
t.Errorf("upstreamFor(%q) = %v, want %v", domain, ufr.upstreams, want)
}
}
})
}
}
// Ordering is a generation concern; it must not make an unchanged list look
// changed at refresh time.
func TestInternalDomainsOrderingIsRefreshStable(t *testing.T) {
apiOrder := []controld.SplitDNS{
{Domain: "example.com"},
{Domain: "z.example.com", Resolvers: []string{"10.0.0.2"}},
}
if !internalDomainsEqual(apiOrder, orderInternalDomainsBySpecificity(apiOrder)) {
t.Error("reordering for specificity must not read as a configuration change")
}
first, second := internalDomainsTestConfig(), internalDomainsTestConfig()
applyInternalDomains(first, apiOrder)
applyInternalDomains(second, orderInternalDomainsBySpecificity(apiOrder))
if len(first.Listener["0"].Policy.Rules) != len(second.Listener["0"].Policy.Rules) {
t.Error("generation must not depend on the order the API returned")
}
}
// Generated Internal Domain upstreams carry the organization's private resolver
// addresses. The production setup path must not publish them above debug.
func TestInternalDomainsSetupKeepsResolverAddressesOutOfInfoLogs(t *testing.T) {
buf := captureInternalDomainsLogs(t)
const secret = "10.77.88.99"
cfg := internalDomainsTestConfig()
applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "corp.example", Resolvers: []string{secret}},
})
// Drop the Control D upstream: setupUpstream would try to bootstrap it.
delete(cfg.Upstream, "0")
p := newInternalDomainsProg(t, cfg)
before := len(buf.String())
p.setupUpstream(cfg)
out := buf.String()[before:]
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line == "" || strings.Contains(line, `"level":"debug"`) {
continue
}
if strings.Contains(line, secret) {
t.Errorf("resolver address disclosed above debug level: %s", line)
}
}
if !strings.Contains(out, secret) {
t.Errorf("expected the address at debug level for diagnosis, got: %s", out)
}
}
// A transport error names the endpoint it could not reach. For an Internal
// Domain upstream that endpoint is private, so only a classification may appear
// at error level.
func TestInternalDomainsResolverFailureLogsNoAddress(t *testing.T) {
buf := captureInternalDomainsLogs(t)
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
dead := pc.LocalAddr().String()
_ = pc.Close()
cfg := internalDomainsTestConfig()
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
applyInternalDomains(cfg, []controld.SplitDNS{{Domain: "corp.example", Resolvers: []string{dead}}})
p := newInternalDomainsProg(t, cfg)
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx := context.WithValue(ctrld.LoggerCtx(context.Background(), p.logger.Load()), ctrld.ReqIdCtxKey{}, requestID())
ufr := p.upstreamFor(ctx, "0", cfg.Listener["0"], addr, "", "host.corp.example")
before := len(buf.String())
p.proxy(ctx, &proxyRequest{msg: newDnsMsgWithHostname("host.corp.example.", dns.TypeA), ufr: ufr})
out := buf.String()[before:]
host, _, _ := net.SplitHostPort(dead)
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if line == "" || strings.Contains(line, `"level":"debug"`) {
continue
}
if strings.Contains(line, dead) || strings.Contains(line, host+":") {
t.Errorf("resolver address disclosed above debug level: %s", line)
}
}
if !strings.Contains(out, `"failure":`) {
t.Errorf("expected a classified failure at error level, got: %s", out)
}
if !strings.Contains(out, dead) {
t.Error("private resolver address must remain available at debug")
}
// Master's transport-level diagnostic must stay at error for ordinary
// legacy upstreams; only generated Internal Domain queries opt into privacy.
before = len(buf.String())
p.queryUpstream(ctx, &proxyRequest{msg: newDnsMsgWithHostname("public.example.", dns.TypeA)},
"upstream.0", cfg.Upstream["internal_0"])
ordinaryError := false
for _, line := range strings.Split(buf.String()[before:], "\n") {
if strings.Contains(line, `"level":"error"`) && strings.Contains(line, "Legacy request failed") && strings.Contains(line, dead) {
ordinaryError = true
}
}
if !ordinaryError {
t.Error("ordinary legacy resolver lost its error-level transport diagnostic")
}
}
// selfUninstallEligibility runs the production setup path and reports whether
// the REFUSED-triggered deletion check stays available.
func selfUninstallEligibility(t *testing.T, p *prog, cfg *ctrld.Config) bool {
t.Helper()
p.setupUpstream(cfg)
return p.canSelfUninstall.Load()
}
// Generated Internal Domain resolvers are part of the managed configuration.
// Counting them as independent upstreams made an ordinary managed install look
// custom, which silently disabled the REFUSED-triggered deletion check.
func TestInternalDomainsKeepSelfUninstallEligibility(t *testing.T) {
for _, tc := range []struct {
name string
entries []controld.SplitDNS
extra map[string]*ctrld.UpstreamConfig
want bool
}{
{name: "no internal domains", want: true},
{
name: "os mode",
entries: []controld.SplitDNS{{Domain: "corp.example", Mode: controld.SplitDNSModeOS}},
want: true,
},
{
name: "explicit mode",
entries: []controld.SplitDNS{{Domain: "corp.example", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}}},
want: true,
},
{
name: "several explicit domains",
entries: []controld.SplitDNS{
{Domain: "corp.example", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53", "10.0.0.54"}},
{Domain: "lab.example", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.55"}},
},
want: true,
},
{
// A genuinely custom multi-upstream install keeps its safeguard.
name: "a real second upstream",
extra: map[string]*ctrld.UpstreamConfig{"1": {Endpoint: "https://dns.example.net/x", Type: ctrld.ResolverTypeDOH, BootstrapIP: "192.0.2.1"}},
want: false,
},
{
// Only the shape applyInternalDomains produces is discounted, so a
// custom config cannot buy eligibility by naming an upstream
// "internal_0".
name: "an upstream merely named like a generated one",
extra: map[string]*ctrld.UpstreamConfig{"internal_0": {Name: "mine", Endpoint: "https://dns.example.net/x", Type: ctrld.ResolverTypeDOH, BootstrapIP: "192.0.2.1"}},
want: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := internalDomainsTestConfig()
applyInternalDomains(cfg, tc.entries)
for k, uc := range tc.extra {
cfg.Upstream[k] = uc
}
p := newInternalDomainsProg(t, cfg)
if got := selfUninstallEligibility(t, p, cfg); got != tc.want {
t.Errorf("canSelfUninstall = %v, want %v (upstreams: %d)", got, tc.want, len(cfg.Upstream))
}
})
}
}
// Eligibility is recomputed on every setup, so a reload that adds a real second
// upstream withdraws it and one that removes Internal Domains keeps it. The old
// code only ever raised the flag, which made startup and refresh disagree.
func TestSelfUninstallEligibilityRecomputedAcrossReloads(t *testing.T) {
explicit := []controld.SplitDNS{{Domain: "corp.example", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}}}
cfg := internalDomainsTestConfig()
applyInternalDomains(cfg, explicit)
p := newInternalDomainsProg(t, cfg)
if !selfUninstallEligibility(t, p, cfg) {
t.Fatal("fresh start with explicit Internal Domains must stay eligible")
}
// Reload: Internal Domains removed. Still a plain managed install.
removed := internalDomainsTestConfig()
if !selfUninstallEligibility(t, p, removed) {
t.Error("removing Internal Domains must keep eligibility")
}
// Reload: a genuinely custom second upstream appears.
custom := internalDomainsTestConfig()
custom.Upstream["1"] = &ctrld.UpstreamConfig{Endpoint: "https://dns.example.net/x", Type: ctrld.ResolverTypeDOH, BootstrapIP: "192.0.2.1"}
if selfUninstallEligibility(t, p, custom) {
t.Error("a real second upstream must withdraw eligibility, not stay latched on")
}
// Reload: back to the managed config with Internal Domains.
again := internalDomainsTestConfig()
applyInternalDomains(again, explicit)
if !selfUninstallEligibility(t, p, again) {
t.Error("eligibility must come back with the managed config")
}
}
// The visible consequence: with explicit Internal Domains configured, a REFUSED
// answer is still counted toward the deletion check instead of being ignored.
// Counting alone performs no uninstall; that stays gated on the API confirming
// the device is gone.
func TestInternalDomainsKeepRefusedQueryCounting(t *testing.T) {
cfg := internalDomainsTestConfig()
applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "corp.example", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}},
})
p := newInternalDomainsProg(t, cfg)
p.setupUpstream(cfg)
refused := new(dns.Msg)
refused.SetRcode(newDnsMsgWithHostname("host.corp.example.", dns.TypeA), dns.RcodeRefused)
p.doSelfUninstall(&proxyResponse{answer: refused, refused: false})
p.selfUninstallMu.Lock()
count, checking := p.refusedQueryCount, p.checkingSelfUninstall
p.selfUninstallMu.Unlock()
if count != 1 {
t.Errorf("refusedQueryCount = %d, want 1", count)
}
if checking {
t.Error("a single REFUSED answer must not start a device-status check")
}
}
// logLinesAboveDebug returns the log lines emitted since mark that a support
// bundle reader would see at info level or above.
func logLinesAboveDebug(buf *syncBuffer, mark int) []string {
var out []string
for _, line := range strings.Split(strings.TrimSpace(buf.String()[mark:]), "\n") {
if line == "" || strings.Contains(line, `"level":"debug"`) {
continue
}
out = append(out, line)
}
return out
}
// deadUDPAddr returns a loopback address with nothing listening, so a dial
// fails fast and locally with connection refused.
func deadUDPAddr(t *testing.T) string {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := pc.LocalAddr().String()
_ = pc.Close()
return addr
}
// blackholeUDPAddr returns a loopback address that accepts packets and never
// answers, so an exchange fails with a timeout rather than a refusal.
func blackholeUDPAddr(t *testing.T) string {
t.Helper()
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = pc.Close() })
return pc.LocalAddr().String()
}
// internalDomainsProbeConfig generates an explicit Internal Domain pointing at
// addr, with a short timeout so a blackholed probe fails quickly.
func internalDomainsProbeConfig(t *testing.T, addr string) *ctrld.Config {
t.Helper()
cfg := internalDomainsTestConfig()
applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "corp.example", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{addr}},
})
uc := cfg.Upstream["internal_0"]
if uc == nil {
t.Fatal("no internal upstream generated")
}
uc.Timeout = 300
uc.Init(ctrld.LoggerCtx(context.Background(), mainLog.Load()))
return cfg
}
// The loop checker runs on a one-minute ticker and probes every upstream whose
// address is local, which every generated Internal Domain resolver is. No user
// query is involved, so without the privacy treatment it publishes the
// organization's resolver addresses on its own schedule.
func TestInternalDomainsLoopCheckHidesResolverAddress(t *testing.T) {
buf := captureInternalDomainsLogs(t)
for _, tc := range []struct {
name string
addr func(*testing.T) string
}{
{"connection refused", deadUDPAddr},
{"timeout", blackholeUDPAddr},
} {
t.Run(tc.name, func(t *testing.T) {
addr := tc.addr(t)
host, _, _ := net.SplitHostPort(addr)
cfg := internalDomainsProbeConfig(t, addr)
p := newInternalDomainsProg(t, cfg)
p.loop = make(map[string]bool)
// Only the generated upstream is local, so it is the only one probed.
delete(cfg.Upstream, "0")
mark := len(buf.String())
p.checkDnsLoop()
above := logLinesAboveDebug(buf, mark)
for _, line := range above {
if strings.Contains(line, addr) || strings.Contains(line, host+":") {
t.Errorf("loop check disclosed the resolver address: %s", line)
}
}
if len(above) == 0 {
t.Error("the failure must stay visible above debug")
}
var classified bool
for _, line := range above {
if strings.Contains(line, `"failure":`) {
classified = true
}
}
if !classified {
t.Errorf("expected a classified failure, got: %v", above)
}
if !strings.Contains(buf.String()[mark:], addr) {
t.Error("the address must remain at debug level for diagnosis")
}
})
}
}
// Preventing an Internal Domain query from starting recovery does not keep its
// resolver out of recovery started for another reason: buildRecoveryUpstreams
// takes every non-OS upstream.
func TestInternalDomainsRecoveryCheckHidesResolverAddress(t *testing.T) {
buf := captureInternalDomainsLogs(t)
for _, tc := range []struct {
name string
addr func(*testing.T) string
}{
{"connection refused", deadUDPAddr},
{"timeout", blackholeUDPAddr},
} {
t.Run(tc.name, func(t *testing.T) {
addr := tc.addr(t)
host, _, _ := net.SplitHostPort(addr)
cfg := internalDomainsProbeConfig(t, addr)
p := newInternalDomainsProg(t, cfg)
upstreams := p.buildRecoveryUpstreams(RecoveryReasonNetworkChange)
uc, selected := upstreams["upstream.internal_0"]
if !selected {
t.Fatalf("recovery upstreams = %v, want the Internal Domain resolver included", upstreams)
}
mark := len(buf.String())
if err := p.checkUpstreamOnce("upstream.internal_0", uc); err == nil {
t.Fatal("expected the probe to fail")
}
above := logLinesAboveDebug(buf, mark)
for _, line := range above {
if strings.Contains(line, addr) || strings.Contains(line, host+":") {
t.Errorf("recovery check disclosed the resolver address: %s", line)
}
}
var classified bool
for _, line := range above {
if strings.Contains(line, `"failure":`) {
classified = true
}
}
if !classified {
t.Errorf("expected a classified failure above debug, got: %v", above)
}
if !strings.Contains(buf.String()[mark:], addr) {
t.Error("the address must remain at debug level for diagnosis")
}
})
}
}
+648
View File
@@ -0,0 +1,648 @@
package cli
import (
"context"
"net"
"strings"
"testing"
"github.com/miekg/dns"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
// captureInternalDomainsLogs uses a test-local JSON sink so counts and privacy
// assertions inspect structured fields at their actual levels, independent of
// TestMain's console encoder and any previous test replacing mainLog. Call it
// before constructing a prog: its logger and monitor retain the selected logger.
func captureInternalDomainsLogs(t *testing.T) *syncBuffer {
t.Helper()
buf := &syncBuffer{}
core := zapcore.NewCore(
zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
zapcore.AddSync(buf),
zap.DebugLevel,
)
old := mainLog.Load()
mainLog.Store(&ctrld.Logger{Logger: zap.New(core)})
t.Cleanup(func() { mainLog.Store(old) })
return buf
}
func internalDomainsTestConfig() *ctrld.Config {
return &ctrld.Config{
Listener: map[string]*ctrld.ListenerConfig{"0": {Policy: &ctrld.ListenerPolicyConfig{Name: "My Policy"}}},
Upstream: map[string]*ctrld.UpstreamConfig{"0": {Endpoint: "https://dns.controld.com/test", Type: ctrld.ResolverTypeDOH, BootstrapIP: "127.0.0.1"}},
Network: map[string]*ctrld.NetworkConfig{"0": {Name: "Network 0", Cidrs: []string{"0.0.0.0/0"}}},
}
}
// ruleTargets returns the targets of the rule for source, and whether it exists.
func ruleTargets(t *testing.T, cfg *ctrld.Config, source string) ([]string, bool) {
t.Helper()
for _, rule := range cfg.Listener["0"].Policy.Rules {
if targets, ok := rule[source]; ok {
return targets, true
}
}
return nil, false
}
func TestNormalizeInternalDomain(t *testing.T) {
long63 := strings.Repeat("a", 63)
for _, tc := range []struct{ name, in, want string }{
// Canonicalization that cannot change which names match.
{"plain", "aws.example.com", "aws.example.com"},
{"case and root dot", " AWS.Example.COM. ", "aws.example.com"},
{"leading wildcard", "*.aws.example.com", "aws.example.com"},
{"surrounding control whitespace", "\r\nCORP.EXAMPLE.\r\n", "corp.example"},
{"single label", "intranet", "intranet"},
{"punycode", "xn--80ak6aa92e.com", "xn--80ak6aa92e.com"},
{"digits and hyphens", "dc-01.site2.example.com", "dc-01.site2.example.com"},
{"max label", long63 + ".example.com", long63 + ".example.com"},
// Rejected: an empty or absent name.
{"empty", "", ""},
{"whitespace only", " ", ""},
{"root only", ".", ""},
{"wildcard only", "*.", ""},
// Rejected: empty labels. A leading dot is not repaired, because the
// repaired value is a different suffix from the configured one.
{"leading dot", ".aws.example.com.", ""},
{"double dot", "aws..example.com", ""},
{"trailing double dot", "aws.example.com..", ""},
// Rejected: characters outside the ASCII hostname contract. A denylist
// would have to name every one of these; the positive rule does not.
{"space", "has space.com", ""},
{"embedded wildcard", "we*rd.com", ""},
{"slash", "a/b.com", ""},
{"newline", "aws\nevil.example.com", ""},
{"carriage return", "aws\revil.example.com", ""},
{"nul", "aws\x00evil.example.com", ""},
{"escape", "aws\x1bevil.example.com", ""},
{"delete", "aws\x7fevil.example.com", ""},
{"quote", "aws\"evil.example.com", ""},
{"colon", "aws:53.example.com", ""},
{"underscore", "_msdcs.example.com", ""},
{"non-ascii", "exämple.com", ""},
// Rejected: hyphen placement and length.
{"leading hyphen", "-aws.example.com", ""},
{"trailing hyphen", "aws-.example.com", ""},
{"label too long", strings.Repeat("a", 64) + ".example.com", ""},
{"name too long", strings.Repeat("a.", 127) + "example.com", ""},
} {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeInternalDomain(tc.in); got != tc.want {
t.Errorf("normalizeInternalDomain(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// A rejected domain is counted and produces no rule, rather than being repaired
// into a suffix nobody configured.
func TestApplyInternalDomainsRejectsMalformedDomains(t *testing.T) {
cfg := internalDomainsTestConfig()
summary := applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "aws\nevil.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}},
{Domain: "ok.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.54"}},
})
if summary.domains != 1 || summary.skipped != 1 {
t.Fatalf("summary = %+v", summary)
}
for _, rule := range cfg.Listener["0"].Policy.Rules {
for source := range rule {
if strings.ContainsAny(source, "\n\r\x00") {
t.Errorf("control character reached a policy rule: %q", source)
}
if source != "ok.example.com" && source != "*.ok.example.com" {
t.Errorf("unexpected rule source %q", source)
}
}
}
}
func TestInternalDomainEndpoint(t *testing.T) {
for _, tc := range []struct {
in string
want string
ok bool
}{
{"10.0.0.10", "10.0.0.10:53", true},
{" 10.0.0.11 ", "10.0.0.11:53", true},
{"2001:db8::1", "[2001:db8::1]:53", true},
{"10.0.0.10:5353", "10.0.0.10:5353", true},
{"[2001:db8::1]:5353", "[2001:db8::1]:5353", true},
{"dns.internal.example.com", "", false},
{"10.0.0.256", "", false},
{"", "", false},
{"10.0.0.10:0", "", false},
{"10.0.0.10:70000", "", false},
{"10.0.0.10:dns", "", false},
} {
got, ok := internalDomainEndpoint(tc.in)
if got != tc.want || ok != tc.ok {
t.Errorf("internalDomainEndpoint(%q) = %q, %v; want %q, %v", tc.in, got, ok, tc.want, tc.ok)
}
}
}
func TestApplyInternalDomainsOSResolverMode(t *testing.T) {
cfg := internalDomainsTestConfig()
summary := applyInternalDomains(cfg, []controld.SplitDNS{{Domain: "corp.example.com"}})
if summary.domains != 1 || summary.osMode != 1 || summary.explicit != 0 || summary.resolvers != 0 {
t.Fatalf("summary = %+v", summary)
}
for _, source := range []string{"corp.example.com", "*.corp.example.com"} {
targets, ok := ruleTargets(t, cfg, source)
if !ok {
t.Fatalf("no rule for %q", source)
}
if len(targets) != 0 {
t.Errorf("rule %q targets = %v, want empty (OS resolver)", source, targets)
}
}
if len(cfg.Upstream) != 1 {
t.Errorf("upstreams = %v, want only the Control D upstream", cfg.Upstream)
}
}
func TestApplyInternalDomainsExplicitResolverMode(t *testing.T) {
cfg := internalDomainsTestConfig()
summary := applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "2001:db8::11"}},
})
if summary.domains != 1 || summary.explicit != 1 || summary.osMode != 0 || summary.resolvers != 2 {
t.Fatalf("summary = %+v", summary)
}
want := []string{"upstream.internal_0", "upstream.internal_1"}
for _, source := range []string{"aws.example.com", "*.aws.example.com"} {
targets, ok := ruleTargets(t, cfg, source)
if !ok {
t.Fatalf("no rule for %q", source)
}
if strings.Join(targets, ",") != strings.Join(want, ",") {
t.Errorf("rule %q targets = %v, want %v (administrator order)", source, targets, want)
}
}
if got := cfg.Upstream["internal_0"]; got == nil || got.Endpoint != "10.0.0.10:53" || got.Type != ctrld.ResolverTypeLegacy {
t.Errorf("internal_0 = %+v", got)
}
if got := cfg.Upstream["internal_1"]; got == nil || got.Endpoint != "[2001:db8::11]:53" {
t.Errorf("internal_1 = %+v", got)
}
}
func TestApplyInternalDomainsMalformedResolvers(t *testing.T) {
t.Run("drops only the malformed address", func(t *testing.T) {
cfg := internalDomainsTestConfig()
summary := applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"not-an-ip", "10.0.0.10"}},
})
if summary.domains != 1 || summary.resolvers != 1 || summary.skipped != 0 {
t.Fatalf("summary = %+v", summary)
}
targets, _ := ruleTargets(t, cfg, "aws.example.com")
if len(targets) != 1 || targets[0] != "upstream.internal_0" {
t.Errorf("targets = %v", targets)
}
})
t.Run("drops the entry when no address is usable", func(t *testing.T) {
cfg := internalDomainsTestConfig()
summary := applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"not-an-ip", ""}},
})
if summary.domains != 0 || summary.skipped != 1 {
t.Fatalf("summary = %+v", summary)
}
// Falling back to the OS resolver here would silently replace the
// administrator's explicit selection with a different one.
if _, ok := ruleTargets(t, cfg, "aws.example.com"); ok {
t.Error("entry with no usable resolver must not produce a rule")
}
if len(cfg.Upstream) != 1 {
t.Errorf("upstreams = %v, want no generated upstream", cfg.Upstream)
}
})
t.Run("drops an unusable domain", func(t *testing.T) {
cfg := internalDomainsTestConfig()
summary := applyInternalDomains(cfg, []controld.SplitDNS{{Domain: " ", Resolvers: []string{"10.0.0.10"}}})
if summary.domains != 0 || summary.skipped != 1 || len(cfg.Upstream) != 1 {
t.Fatalf("summary = %+v, upstreams = %v", summary, cfg.Upstream)
}
})
t.Run("drops a duplicate domain", func(t *testing.T) {
cfg := internalDomainsTestConfig()
summary := applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
{Domain: "AWS.Example.com.", Resolvers: []string{"10.0.0.99"}},
})
if summary.domains != 1 || summary.skipped != 1 {
t.Fatalf("summary = %+v", summary)
}
targets, _ := ruleTargets(t, cfg, "aws.example.com")
if len(targets) != 1 || cfg.Upstream["internal_0"].Endpoint != "10.0.0.10:53" {
t.Errorf("first entry must win: targets = %v", targets)
}
})
}
// Magic Folder excludes and endpoint custom configuration are written to the
// policy before Internal Domains are applied, and must keep their routing.
func TestApplyInternalDomainsPrecedence(t *testing.T) {
t.Run("an existing rule is never overwritten", func(t *testing.T) {
cfg := internalDomainsTestConfig()
cfg.Listener["0"].Policy.Rules = []ctrld.Rule{{"aws.example.com": []string{}}}
summary := applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
})
targets, _ := ruleTargets(t, cfg, "aws.example.com")
if len(targets) != 0 {
t.Errorf("exclude rule was overwritten: targets = %v", targets)
}
// The wildcard was still free, so the domain is only partly preempted.
if wildcard, ok := ruleTargets(t, cfg, "*.aws.example.com"); !ok || len(wildcard) != 1 {
t.Errorf("wildcard rule = %v, %v", wildcard, ok)
}
if summary.domains != 1 {
t.Errorf("summary = %+v", summary)
}
})
t.Run("a fully covered domain is reported as preempted", func(t *testing.T) {
cfg := internalDomainsTestConfig()
cfg.Listener["0"].Policy.Rules = []ctrld.Rule{
{"aws.example.com": []string{"upstream.0"}},
{"*.aws.example.com": []string{"upstream.0"}},
}
summary := applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
})
if summary.domains != 0 || summary.preempted != 1 {
t.Fatalf("summary = %+v", summary)
}
if len(cfg.Listener["0"].Policy.Rules) != 2 {
t.Errorf("rules = %v, want no addition", cfg.Listener["0"].Policy.Rules)
}
})
}
// A configured suffix must route itself and its subdomains, and nothing else.
func TestApplyInternalDomainsSuffixMatching(t *testing.T) {
cfg := internalDomainsTestConfig()
applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
{Domain: "corp.example.com"},
})
p := newInternalDomainsProg(t, cfg)
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
domain string
want []string
}{
{"aws.example.com", []string{"upstream.internal_0"}},
{"host.aws.example.com", []string{"upstream.internal_0"}},
{"a.b.c.aws.example.com", []string{"upstream.internal_0"}},
{"corp.example.com", []string{}},
{"host.corp.example.com", []string{}},
{"example.com", []string{"upstream.0"}},
{"notaws.example.com", []string{"upstream.0"}},
{"aws.example.com.evil.test", []string{"upstream.0"}},
} {
ufr := p.upstreamFor(context.Background(), "0", cfg.Listener["0"], addr, "", tc.domain)
if strings.Join(ufr.upstreams, ",") != strings.Join(tc.want, ",") {
t.Errorf("upstreamFor(%q) = %v, want %v", tc.domain, ufr.upstreams, tc.want)
}
}
}
// A refresh rebuilds the generated config, so a removal must leave no rule and
// no upstream behind.
func TestApplyInternalDomainsAuthoritativeReplacement(t *testing.T) {
first := internalDomainsTestConfig()
applyInternalDomains(first, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
{Domain: "old.example.com", Resolvers: []string{"10.0.0.20"}},
})
if len(first.Upstream) != 3 {
t.Fatalf("upstreams = %v", first.Upstream)
}
// The refresh path regenerates cfg from the API response rather than
// mutating the previous one.
second := internalDomainsTestConfig()
summary := applyInternalDomains(second, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.99"}},
})
if summary.domains != 1 || summary.resolvers != 1 {
t.Fatalf("summary = %+v", summary)
}
if _, ok := ruleTargets(t, second, "old.example.com"); ok {
t.Error("removed domain left a stale rule")
}
if len(second.Upstream) != 2 {
t.Errorf("upstreams = %v, want the Control D upstream plus one internal", second.Upstream)
}
if second.Upstream["internal_0"].Endpoint != "10.0.0.99:53" {
t.Errorf("resolver change not applied: %+v", second.Upstream["internal_0"])
}
}
func TestApplyInternalDomainsEmpty(t *testing.T) {
for _, entries := range [][]controld.SplitDNS{nil, {}} {
cfg := internalDomainsTestConfig()
summary := applyInternalDomains(cfg, entries)
if !summary.empty() {
t.Errorf("summary = %+v, want empty", summary)
}
if len(cfg.Listener["0"].Policy.Rules) != 0 || len(cfg.Upstream) != 1 {
t.Errorf("absent split_dns changed the config: rules=%v upstreams=%v",
cfg.Listener["0"].Policy.Rules, cfg.Upstream)
}
}
}
func TestInternalDomainsEqual(t *testing.T) {
base := []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
{Domain: "corp.example.com"},
}
for _, tc := range []struct {
name string
next []controld.SplitDNS
want bool
}{
{"unchanged", base, true},
{"reordered domains", []controld.SplitDNS{
{Domain: "corp.example.com"},
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
}, true},
{"equivalent spelling", []controld.SplitDNS{
{Domain: "AWS.Example.com.", Resolvers: []string{" 10.0.0.10 ", "10.0.0.11:53"}},
{Domain: "*.corp.example.com"},
}, true},
{"resolver order changed", []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.11", "10.0.0.10"}},
{Domain: "corp.example.com"},
}, false},
{"resolver changed", []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.99"}},
{Domain: "corp.example.com"},
}, false},
{"domain removed", base[:1], false},
{"domain added", append(append([]controld.SplitDNS{}, base...), controld.SplitDNS{Domain: "new.example.com"}), false},
{"mode changed to explicit", []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
{Domain: "corp.example.com", Resolvers: []string{"10.0.0.30"}},
}, false},
{"emptied", nil, false},
} {
if got := internalDomainsEqual(base, tc.next); got != tc.want {
t.Errorf("%s: internalDomainsEqual = %v, want %v", tc.name, got, tc.want)
}
}
if !internalDomainsEqual(nil, []controld.SplitDNS{}) {
t.Error("nil and empty lists must compare equal")
}
}
func TestInternalDomainExplicitUpstreams(t *testing.T) {
for _, tc := range []struct {
in []string
want bool
}{
{[]string{"upstream.internal_0"}, true},
{[]string{"upstream.internal_0", "upstream.internal_1"}, true},
{[]string{"upstream.internal_0", "upstream.0"}, false},
{[]string{"upstream.0"}, false},
{[]string{upstreamOS}, false},
{nil, false},
{[]string{}, false},
} {
if got := internalDomainExplicitUpstreams(tc.in); got != tc.want {
t.Errorf("internalDomainExplicitUpstreams(%v) = %v, want %v", tc.in, got, tc.want)
}
}
}
// Info and warning logs must not carry organization domain names or resolver
// addresses; those stay at debug level.
func TestInternalDomainsSummaryLogsCountsOnly(t *testing.T) {
buf := captureInternalDomainsLogs(t)
logger := mainLog.Load()
logInternalDomainsSummary(logger, internalDomainsSummary{
domains: 2, osMode: 1, explicit: 1, resolvers: 3, skipped: 1,
})
out := buf.String()
for _, want := range []string{`"domains":2`, `"os_resolver":1`, `"explicit_resolver":1`, `"resolvers":3`, `"skipped":1`} {
if !strings.Contains(out, want) {
t.Errorf("log %q missing %q", out, want)
}
}
if !strings.Contains(out, `"level":"warn"`) {
t.Errorf("a summary with skipped entries must warn: %q", out)
}
mark := len(buf.String())
logInternalDomainsSummary(logger, internalDomainsSummary{domains: 1, osMode: 1})
if !strings.Contains(buf.String()[mark:], `"level":"info"`) {
t.Errorf("a clean summary must be info: %q", buf.String())
}
}
// An explicit-resolver Internal Domain must not be handed to VPN DNS split
// routing: the administrator named those resolvers, and VPN suffixes are
// auto-detected. When the named resolvers are unreachable the query fails
// instead of reaching the OS resolver.
func TestInternalDomainsBeatVPNSplitRoutingAndDoNotLeak(t *testing.T) {
buf := captureInternalDomainsLogs(t)
cfg := internalDomainsTestConfig()
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(true)
applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"127.0.0.1:1"}},
})
p := newInternalDomainsProg(t, cfg)
p.vpnDNS = &vpnDNSManager{routes: map[string][]string{"example.com": {"127.0.0.1:1"}}}
prevIntercept := dnsIntercept
dnsIntercept = true
t.Cleanup(func() { dnsIntercept = prevIntercept })
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx := context.WithValue(ctrld.LoggerCtx(context.Background(), p.logger.Load()), ctrld.ReqIdCtxKey{}, requestID())
ufr := p.upstreamFor(ctx, "0", cfg.Listener["0"], addr, "", "host.aws.example.com")
if !internalDomainExplicitUpstreams(ufr.upstreams) {
t.Fatalf("upstreams = %v, want only internal resolvers", ufr.upstreams)
}
// Only this call's tail describes what proxy() did here.
before := len(buf.String())
// A trailing dot: without it the name is not a valid wire question, and the
// failure would prove nothing about whether a resolver was contacted.
res := p.proxy(ctx, &proxyRequest{msg: newDnsMsgWithHostname("host.aws.example.com.", dns.TypeA), ufr: ufr})
out := buf.String()[before:]
if strings.Contains(out, "VPN DNS route matched") {
t.Errorf("VPN split routing overrode an explicit Internal Domain: %s", out)
}
if strings.Contains(out, "Attempting query to OS resolver as a retry catch all") {
t.Errorf("unreachable internal resolvers leaked to the OS resolver: %s", out)
}
if res == nil || res.answer == nil || res.answer.Rcode != dns.RcodeServerFailure {
t.Errorf("answer = %v, want SERVFAIL", res)
}
}
// A domain that produces no rule must produce no upstream either, or the
// generated config would carry an internal_* upstream nothing refers to.
func TestApplyInternalDomainsLeaveNoOrphanUpstreams(t *testing.T) {
cfg := internalDomainsTestConfig()
cfg.Listener["0"].Policy.Rules = []ctrld.Rule{
{"aws.example.com": []string{"upstream.0"}},
{"*.aws.example.com": []string{"upstream.0"}},
}
summary := applyInternalDomains(cfg, []controld.SplitDNS{
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
{Domain: "ok.example.com", Resolvers: []string{"10.0.0.20"}},
})
if summary.preempted != 1 || summary.domains != 1 || summary.resolvers != 1 {
t.Fatalf("summary = %+v", summary)
}
for key, uc := range cfg.Upstream {
if !strings.HasPrefix(key, internalDomainUpstreamPrefix) {
continue
}
if uc.Endpoint != "10.0.0.20:53" {
t.Errorf("orphan upstream %s = %+v", key, uc)
}
}
if len(cfg.Upstream) != 2 {
t.Errorf("upstreams = %v, want the Control D upstream plus one internal", cfg.Upstream)
}
}
// Mode is what the administrator selected, so it decides the routing. The list
// of resolvers is only read when the mode calls for it.
func TestApplyInternalDomainsModeIsAuthoritative(t *testing.T) {
for _, tc := range []struct {
name string
entry controld.SplitDNS
wantTargets []string
wantRule bool
wantUpstreams int
}{
{
// The administrator switched back to the OS resolver; addresses the
// previous selection left behind must not resurrect themselves.
name: "os mode ignores leftover resolvers",
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeOS, Resolvers: []string{"10.0.0.53"}},
wantTargets: []string{},
wantRule: true,
wantUpstreams: 1,
},
{
// Explicit with nothing to be explicit about is not OS resolution.
name: "resolvers mode with an empty list is dropped",
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers},
wantRule: false,
wantUpstreams: 1,
},
{
name: "resolvers mode with only malformed addresses is dropped",
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"nope"}},
wantRule: false,
wantUpstreams: 1,
},
{
name: "resolvers mode routes explicitly",
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}},
wantTargets: []string{"upstream.internal_0"},
wantRule: true,
wantUpstreams: 2,
},
{
name: "mode is matched case and space insensitively",
entry: controld.SplitDNS{Domain: "a.example.com", Mode: " OS ", Resolvers: []string{"10.0.0.53"}},
wantTargets: []string{},
wantRule: true,
wantUpstreams: 1,
},
{
// A mode ctrld does not know is not guessed at.
name: "unknown mode is dropped",
entry: controld.SplitDNS{Domain: "a.example.com", Mode: "forward-someday", Resolvers: []string{"10.0.0.53"}},
wantRule: false,
wantUpstreams: 1,
},
{
// A deployment that predates the field: the list is the only signal.
name: "absent mode infers explicit",
entry: controld.SplitDNS{Domain: "a.example.com", Resolvers: []string{"10.0.0.53"}},
wantTargets: []string{"upstream.internal_0"},
wantRule: true,
wantUpstreams: 2,
},
{
name: "absent mode infers os",
entry: controld.SplitDNS{Domain: "a.example.com"},
wantTargets: []string{},
wantRule: true,
wantUpstreams: 1,
},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := internalDomainsTestConfig()
applyInternalDomains(cfg, []controld.SplitDNS{tc.entry})
targets, ok := ruleTargets(t, cfg, "a.example.com")
if ok != tc.wantRule {
t.Fatalf("rule present = %v, want %v", ok, tc.wantRule)
}
if ok && strings.Join(targets, ",") != strings.Join(tc.wantTargets, ",") {
t.Errorf("targets = %v, want %v", targets, tc.wantTargets)
}
if len(cfg.Upstream) != tc.wantUpstreams {
t.Errorf("upstreams = %v, want %d", cfg.Upstream, tc.wantUpstreams)
}
})
}
}
// A mode change is a routing change even when the resolver list is untouched,
// so it has to reach the endpoint on the next refresh.
func TestInternalDomainsEqualTracksMode(t *testing.T) {
explicit := []controld.SplitDNS{{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}}}
os := []controld.SplitDNS{{Domain: "a.example.com", Mode: controld.SplitDNSModeOS, Resolvers: []string{"10.0.0.53"}}}
if internalDomainsEqual(explicit, os) {
t.Error("switching to the OS resolver must be detected as a change")
}
if internalDomainsEqual(os, explicit) {
t.Error("switching to explicit resolvers must be detected as a change")
}
// An unusable entry routes nothing, so it must not look like a change.
unusable := []controld.SplitDNS{
{Domain: "a.example.com", Mode: controld.SplitDNSModeOS, Resolvers: []string{"10.0.0.53"}},
{Domain: "b.example.com", Mode: "forward-someday"},
}
if !internalDomainsEqual(os, unusable) {
t.Error("an entry ctrld cannot use must not read as a configuration change")
}
}
+5 -2
View File
@@ -86,6 +86,8 @@ func (p *prog) detectLoop(msg *dns.Msg) {
func (p *prog) checkDnsLoop() {
p.Debug().Msg("Start checking DNS loop")
upstream := make(map[string]*ctrld.UpstreamConfig)
// Preserve generated-upstream identity for privacy-safe probe diagnostics.
generated := make(map[string]bool)
p.loopMu.Lock()
for n, uc := range p.cfg.Upstream {
if p.um.isDown("upstream." + n) {
@@ -99,6 +101,7 @@ func (p *prog) checkDnsLoop() {
uid := uc.UID()
p.loop[uid] = false
upstream[uid] = uc
generated[uid] = isGeneratedInternalDomainUpstream(n, uc)
}
p.loopMu.Unlock()
@@ -112,11 +115,11 @@ func (p *prog) checkDnsLoop() {
}
resolver, err := ctrld.NewResolver(loggerCtx, uc)
if err != nil {
p.Warn().Err(err).Msgf("Could not perform loop check for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
p.logUpstreamProbeFailure(generated[uid], uc, err, p.Warn, "Could not perform loop check")
continue
}
if _, err := resolver.Resolve(context.Background(), msg); err != nil {
p.Warn().Err(err).Msgf("Could not send DNS loop check query for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
p.logUpstreamProbeFailure(generated[uid], uc, err, p.Warn, "Could not send DNS loop check query")
}
}
p.Debug().Msg("End checking DNS loop")
+29 -7
View File
@@ -644,17 +644,25 @@ func (p *prog) applyFetchedResolverConfig(
noCustomConfig := resolverConfig.Ctrld.CustomConfig == ""
noExcludeListChanged := true
// Regenerate Internal Domains on add, removal, domain, mode or resolver change.
noInternalDomainsChanged := true
if rc != nil {
slices.Sort(rc.Exclude)
slices.Sort(resolverConfig.Exclude)
noExcludeListChanged = slices.Equal(rc.Exclude, resolverConfig.Exclude)
noInternalDomainsChanged = internalDomainsEqual(rc.SplitDNS, resolverConfig.SplitDNS)
}
if noCustomConfig && noExcludeListChanged {
if noCustomConfig && noExcludeListChanged && noInternalDomainsChanged {
return lastUpdated
}
if noCustomConfig && !noExcludeListChanged {
logger.Debug().Msg("Exclude list changes detected, reloading...")
if noCustomConfig && (!noExcludeListChanged || !noInternalDomainsChanged) {
if !noExcludeListChanged {
logger.Debug().Msg("Exclude list changes detected, reloading...")
}
if !noInternalDomainsChanged {
logger.Info().Msg("Internal domain changes detected, reloading...")
}
p.firewallOnConfigReload()
p.apiReloadCh <- nil
return lastUpdated
@@ -700,11 +708,18 @@ func (p *prog) setupUpstream(cfg *ctrld.Config) {
p.Debug().Msgf("Initialized dns stamps with endpoint: %s, type: %s", uc.Endpoint, uc.Type)
}
isControlDUpstream = isControlDUpstream || uc.IsControlD()
// Generated resolver bootstrap addresses are private organization data.
bootstrapEvent := func() *ctrld.LogEvent {
if strings.HasPrefix(n, internalDomainUpstreamPrefix) {
return p.Debug()
}
return p.Info()
}
if uc.BootstrapIP == "" {
uc.SetupBootstrapIP(ctrld.LoggerCtx(context.Background(), p.logger.Load()))
p.Info().Msgf("Bootstrap ips for upstream.%s: %q", n, uc.BootstrapIPs())
bootstrapEvent().Msgf("Bootstrap ips for upstream.%s: %q", n, uc.BootstrapIPs())
} else {
p.Info().Str("bootstrap_ip", uc.BootstrapIP).Msgf("Using bootstrap ip for upstream.%s", n)
bootstrapEvent().Str("bootstrap_ip", uc.BootstrapIP).Msgf("Using bootstrap ip for upstream.%s", n)
}
uc.SetCertPool(rootCertPool)
go uc.Ping(loggerCtx)
@@ -717,9 +732,16 @@ func (p *prog) setupUpstream(cfg *ctrld.Config) {
}
}
// Self-uninstallation is ok If there is only 1 ControlD upstream, and no remote config.
if len(cfg.Upstream) == 1 && isControlDUpstream {
p.canSelfUninstall.Store(true)
// Generated Internal Domain resolvers are part of the managed configuration,
// not independent user upstreams. Recompute eligibility on every setup/reload;
// uninstall still requires API confirmation that the device is gone.
managedUpstreams := 0
for n, uc := range cfg.Upstream {
if !isGeneratedInternalDomainUpstream(n, uc) {
managedUpstreams++
}
}
p.canSelfUninstall.Store(managedUpstreams == 1 && isControlDUpstream)
p.localUpstreams = localUpstreams
p.ptrNameservers = ptrNameservers
}