cli: route organization Internal Domains from managed config

Consume resolver.split_dns and generate a policy rule per domain, plus its
wildcard, so a suffix and its subdomains route to the OS resolver when no
resolvers are given, or to the configured resolvers when they are.

Explicit resolvers are exclusive: when all of them fail the query returns
SERVFAIL rather than taking the OS catch-all or starting recovery, and it
skips VPN split routing, which is auto-detected. Excludes and custom config
keep precedence; a refresh regenerates the config, so removals leave no
stale rule or upstream.
This commit is contained in:
Cuong Manh Le committed 2026-09-28 16:45:16 +07:00
1 parent c75989a6ee
commit 7da2507d19
10 files changed
+2175 -20

No files matched your search

+25
View File
@@ -1021,6 +1021,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)
@@ -1028,6 +1036,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 zerolog.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 {
+42 -8
View File
@@ -561,7 +561,14 @@ 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() {
//
// An Internal Domain with explicit resolvers does not take part. The bypass
// exists because general DNS is broken, which says nothing about the
// administrator's own resolvers: they may well be reachable, and sending a
// private name to the network's DNS is the disclosure the explicit
// selection exists to prevent. Such a query continues to the normal flow,
// where it reaches only its configured resolvers and fails if none answer.
if dnsIntercept && p.recoveryBypass.Load() && !internalDomainExplicitUpstreams(req.ufr.upstreams) {
ctrld.Log(ctx, mainLog.Load().Debug(), "Recovery bypass active: forwarding to OS resolver")
resolver, err := ctrld.NewResolver(osUpstreamConfig)
if err == nil {
@@ -688,8 +695,17 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
}
}
// 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).
//
// An Internal Domain with explicit resolvers is skipped here: the
// administrator named the resolvers for that suffix, and VPN suffixes
// are auto-detected, so handing the query to a VPN DNS server would
// override an explicit selection with a discovered one.
//
// Internal Domains in OS-resolver mode are not skipped, they ask for the
// endpoint's default resolution, which under intercept mode includes
// the VPN's own 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, mainLog.Load().Debug(), "VPN DNS route matched for domain %s, using servers: %v", domain, vpnServers)
@@ -836,7 +852,17 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
return answer
}
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "failed to resolve query")
// A transport error names the endpoint it failed to reach, which for a
// generated Internal Domain upstream is the organization's private
// resolver address. Report the classification at error level and keep
// the address-bearing error at debug.
if isInternalDomainUpstream(upstream) {
ctrld.Log(ctx, mainLog.Load().Debug().Err(err), "failed to resolve query")
ctrld.Log(ctx, mainLog.Load().Error().Str("failure", internalDomainFailureReason(err)),
"failed to resolve query using an Internal Domain resolver")
} else {
ctrld.Log(ctx, mainLog.Load().Error().Err(err), "failed to resolve query")
}
// increase failure count when there is no answer
// rehardless of what kind of error we get
@@ -949,8 +975,14 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse {
}
ctrld.Log(ctx, mainLog.Load().Error(), "all %v endpoints failed", upstreams)
// if we have no healthy upstreams, trigger recovery flow
if p.leakOnUpstreamFailure() {
// An Internal Domain with explicit resolvers is served only by the
// configured resolvers. When all of them are unreachable the query fails
// here: the OS-resolver catch-all below would send a private name to a resolver
// that was not selected for it, and the recovery flow exists for the loss of
// general DNS, not for one unavailable internal server.
if internalDomainExplicitUpstreams(upstreams) {
ctrld.Log(ctx, mainLog.Load().Debug(), "internal domain resolvers unreachable; not falling back")
} else if p.leakOnUpstreamFailure() {
if p.um.countHealthy(upstreams) == 0 {
p.recoveryCancelMu.Lock()
if p.recoveryCancel == nil {
@@ -1943,7 +1975,8 @@ func (p *prog) checkUpstreamOnce(upstream string, uc *ctrld.UpstreamConfig) erro
resolver, err := ctrld.NewResolver(uc)
if err != nil {
mainLog.Load().Error().Err(err).Msgf("Failed to create resolver for upstream %s", upstream)
logUpstreamProbeFailure(isGeneratedInternalDomainUpstreamRef(upstream, uc), uc, err,
mainLog.Load().Error, "Failed to create resolver for upstream %s", upstream)
return err
}
@@ -1982,7 +2015,8 @@ func (p *prog) checkUpstreamOnce(upstream string, uc *ctrld.UpstreamConfig) erro
mainLog.Load().Debug().Err(err).Msgf("Upstream %s check failed after %v (network unreachable)", upstream, duration)
return err
}
mainLog.Load().Error().Err(err).Msgf("Upstream %s check failed after %v", upstream, duration)
logUpstreamProbeFailure(isGeneratedInternalDomainUpstreamRef(upstream, uc), uc, err,
mainLog.Load().Error, "Upstream %s check failed after %v", upstream, duration)
return err
}
mainLog.Load().Debug().Msgf("Upstream %s responded successfully in %v", upstream, duration)
+456
View File
@@ -0,0 +1,456 @@
package cli
import (
"context"
"errors"
"net"
"net/netip"
"slices"
"sort"
"strconv"
"strings"
"syscall"
"github.com/rs/zerolog"
"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 logUpstreamProbeFailure(generated bool, uc *ctrld.UpstreamConfig, err error, level func() *zerolog.Event, 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
}
mainLog.Load().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...)
}
+619
View File
@@ -0,0 +1,619 @@
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.um = newUpstreamMonitor(cfg)
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(context.Background(), 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(context.Background(), 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 := &prog{cfg: 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) {
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(logOutput.String())
p.setupUpstream(cfg)
out := logOutput.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) {
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(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
ufr := p.upstreamFor(ctx, "0", cfg.Listener["0"], addr, "", "host.corp.example")
before := len(logOutput.String())
p.proxy(ctx, &proxyRequest{msg: newDnsMsgWithHostname("host.corp.example.", dns.TypeA), ufr: ufr})
out := logOutput.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)
}
}
// 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(refused)
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(mark int) []string {
var out []string
for _, line := range strings.Split(strings.TrimSpace(logOutput.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()
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) {
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(logOutput.String())
p.checkDnsLoop()
above := logLinesAboveDebug(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(logOutput.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) {
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(logOutput.String())
if err := p.checkUpstreamOnce("upstream.internal_0", uc); err == nil {
t.Fatal("expected the probe to fail")
}
above := logLinesAboveDebug(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(logOutput.String()[mark:], addr) {
t.Error("the address must remain at debug level for diagnosis")
}
})
}
}
+632
View File
@@ -0,0 +1,632 @@
package cli
import (
"context"
"net"
"strings"
"testing"
"github.com/miekg/dns"
"github.com/rs/zerolog"
"github.com/Control-D-Inc/ctrld"
"github.com/Control-D-Inc/ctrld/internal/controld"
)
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}},
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 := &prog{cfg: 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) {
var buf strings.Builder
logger := zerolog.New(&buf)
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)
}
buf.Reset()
logInternalDomainsSummary(logger, internalDomainsSummary{domains: 1, osMode: 1})
if !strings.Contains(buf.String(), `"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) {
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 := &prog{cfg: cfg}
p.um = newUpstreamMonitor(p.cfg)
p.lanLoopGuard = newLoopGuard()
p.ptrLoopGuard = newLoopGuard()
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(context.Background(), 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)
}
// logOutput accumulates for the whole test binary; only this call's tail
// describes what proxy() did here.
before := len(logOutput.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 := logOutput.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")
}
}
+7 -2
View File
@@ -86,6 +86,10 @@ func (p *prog) detectLoop(msg *dns.Msg) {
func (p *prog) checkDnsLoop() {
mainLog.Load().Debug().Msg("start checking DNS loop")
upstream := make(map[string]*ctrld.UpstreamConfig)
// Generated Internal Domain resolvers are local addresses, so they pass
// canBeLocalUpstream and are probed every minute. Record which ones they
// are while the config key is still in hand.
generated := make(map[string]bool)
p.loopMu.Lock()
for n, uc := range p.cfg.Upstream {
if p.um.isDown("upstream." + n) {
@@ -99,6 +103,7 @@ func (p *prog) checkDnsLoop() {
uid := uc.UID()
p.loop[uid] = false
upstream[uid] = uc
generated[uid] = isGeneratedInternalDomainUpstream(n, uc)
}
p.loopMu.Unlock()
@@ -111,11 +116,11 @@ func (p *prog) checkDnsLoop() {
}
resolver, err := ctrld.NewResolver(uc)
if err != nil {
mainLog.Load().Warn().Err(err).Msgf("could not perform loop check for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
logUpstreamProbeFailure(generated[uid], uc, err, mainLog.Load().Warn, "could not perform loop check")
continue
}
if _, err := resolver.Resolve(context.Background(), msg); err != nil {
mainLog.Load().Warn().Err(err).Msgf("could not send DNS loop check query for upstream: %q, endpoint: %q", uc.Name, uc.Endpoint)
logUpstreamProbeFailure(generated[uid], uc, err, mainLog.Load().Warn, "could not send DNS loop check query")
}
}
mainLog.Load().Debug().Msg("end checking DNS loop")
+40 -7
View File
@@ -552,17 +552,27 @@ func (p *prog) apiConfigReload() {
p.mu.Unlock()
noCustomConfig := resolverConfig.Ctrld.CustomConfig == ""
noExcludeListChanged := true
// Internal Domains are regenerated from the API response, so an add, a
// resolver change, a domain change and a removal all reach the endpoint
// through the same reload as an exclude-list 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
}
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.apiReloadCh <- nil
return
}
@@ -613,11 +623,21 @@ func (p *prog) setupUpstream(cfg *ctrld.Config) {
mainLog.Load().Debug().Msgf("initialized DNS Stamps with endpoint: %s, type: %s", uc.Endpoint, uc.Type)
}
isControlDUpstream = isControlDUpstream || uc.IsControlD()
// uc.Init copies an IP endpoint straight into BootstrapIP, so for a
// generated Internal Domain upstream these lines would publish the
// organization's private resolver address at info level. Detail for
// those upstreams stays at debug; the summary reports counts instead.
bootstrapEvent := func() *zerolog.Event {
if strings.HasPrefix(n, internalDomainUpstreamPrefix) {
return mainLog.Load().Debug()
}
return mainLog.Load().Info()
}
if uc.BootstrapIP == "" {
uc.SetupBootstrapIP()
mainLog.Load().Info().Msgf("bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs())
bootstrapEvent().Msgf("bootstrap IPs for upstream.%s: %q", n, uc.BootstrapIPs())
} else {
mainLog.Load().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()
@@ -630,9 +650,22 @@ 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 do not count: they come from the
// managed configuration itself, so counting them would read an ordinary
// managed install as a custom multi-upstream one and cost the endpoint the
// REFUSED-triggered deletion check. The value is recomputed rather than
// only raised, so removing the last Internal Domain - or gaining a real
// second upstream on reload - is reflected too. The uninstall itself stays
// gated on the API confirming the device is gone.
managedUpstreams := 0
for n := range cfg.Upstream {
if isGeneratedInternalDomainUpstream(n, cfg.Upstream[n]) {
continue
}
managedUpstreams++
}
p.canSelfUninstall.Store(managedUpstreams == 1 && isControlDUpstream)
p.localUpstreams = localUpstreams
p.ptrNameservers = ptrNameservers
}
+170
View File
@@ -0,0 +1,170 @@
# Organization Internal Domains
Internal Domains let an organization administrator route a domain suffix, and
every subdomain under it, to a chosen resolver. The selection is made in the
Control D dashboard and reaches the endpoint through the managed resolver
configuration, so no hand-edited `ctrld.toml` is involved and changes apply on
the next configuration refresh.
## API contract
The managed resolver configuration carries the list in `resolver.split_dns`:
```json
{
"resolver": {
"split_dns": [
{ "domain": "corp.example.com", "mode": "resolvers", "resolvers": ["10.0.0.53", "10.0.0.54"] },
{ "domain": "office.example.com", "mode": "os", "resolvers": [] }
]
}
}
```
`mode` is what the administrator selected, and it decides the routing:
| `mode` | Behavior |
|---|---|
| `os` | The query goes to `upstream.os`, the endpoint's current system resolver, and follows DHCP and VPN resolver changes. Any addresses still present in `resolvers` are **not** read, so switching a domain back to the OS resolver cannot be undone by leftovers from a previous selection. |
| `resolvers` | The query goes only to the addresses in `resolvers`, tried in the order given. An empty or entirely unusable list does not become OS resolution: the entry is dropped instead. |
| absent | A deployment that predates the field. The mode is inferred from `resolvers`, which is what the field encodes. |
| anything else | Dropped. Routing a private domain by guess is the disclosure this feature exists to prevent. |
A dropped entry leaves its domain on its normal path and is reported in the
`skipped` count.
Addresses may be IPv4 or IPv6, bare or with a port; a bare address uses port 53.
An address that is not a valid IP is dropped from the list.
A domain is lowercased and stripped of surrounding whitespace, a leading `*.`
and the root dot, then validated as an ASCII hostname suffix: 1-63 character
labels, letters, digits and hyphens only, no leading or trailing hyphen, 253
characters overall. Single-label names and punycode are supported. This is the
same rule the API validates against, so anything the dashboard accepted passes.
A value that fails is rejected and counted, never repaired — deleting the
offending characters would produce a different suffix from the configured one,
and routing a private domain to the wrong place is worse than not routing it.
## Generated configuration
Each accepted domain contributes two policy rules, `<domain>` and `*.<domain>`,
which is how ctrld matches a suffix and all of its subdomains. Explicit mode
also generates one upstream per resolver address, named `internal_<n>`:
```toml
[upstream.internal_0]
type = "legacy"
endpoint = "10.0.0.10:53"
[listener.0.policy]
rules = [
{ "aws.example.com" = ["upstream.internal_0", "upstream.internal_1"] },
{ "*.aws.example.com" = ["upstream.internal_0", "upstream.internal_1"] },
{ "corp.internal" = [] },
{ "*.corp.internal" = [] },
]
```
An empty target list is the same representation Magic Folder excludes use, and
`proxy()` resolves it through `upstream.os`.
## Precedence
The generated configuration is built in a fixed order, and a rule that already
exists for a source is never overwritten. From highest precedence to lowest:
1. **Endpoint custom configuration.** A valid `ctrld.custom_config` replaces the
generated configuration outright. Internal Domains are not merged into it,
the same as Magic Folder excludes — if an organization uses a custom config,
its rules are the whole policy.
2. **Magic Folder excludes** (`resolver.exclude`). These are written first, so an
excluded domain keeps its exclusion even if it also appears as an Internal
Domain. Only the exact source collides: an exclude for `aws.example.com` does
not stop the Internal Domain from claiming `*.aws.example.com`.
3. **Internal Domains** (`resolver.split_dns`), most specific suffix first.
The API permits overlapping suffixes and returns the list sorted bytewise by
domain, which puts a parent ahead of its children. Policy rules are matched
in order and the first hit wins, so appending in API order would let
`*.example.com` swallow every query a configured `z.example.com` was meant to
answer. Entries are therefore ordered by label count before generation, and
the ordering is stable, so equally specific entries keep API order and the
first of two identical domains still wins.
4. **Active Directory auto-detection** (Windows). `addExtraSplitDnsRule` runs
after the generated configuration and skips any domain that already has a
rule, so an Internal Domain for the AD domain takes precedence over the
auto-detected one.
**VPN DNS auto-detection** is evaluated inside `proxy()` in DNS-intercept mode,
after policy matching and independently of it, so it needs its own rule:
- An **explicit-resolver** Internal Domain skips VPN routing. The administrator
named the resolvers for that suffix; VPN suffixes are discovered from the OS,
and letting a discovered route override a named one is the leak this feature
exists to prevent.
- An **OS-resolver** Internal Domain does not skip it, and behaves exactly like
a Magic Folder exclude. It asks for the endpoint's default resolution, which
under intercept mode includes the VPN's own resolver.
Every domain that is not an Internal Domain keeps its current VPN routing.
## Unreachable explicit resolvers
When every resolver for an explicit-mode domain fails, the query returns
SERVFAIL. It is deliberately not retried against the OS resolver, and it does
not trigger the endpoint recovery flow:
- Sending a private name to a resolver the administrator did not select is the
leak this feature exists to prevent.
- The recovery flow exists for the loss of general DNS. One unavailable internal
server is not that, and letting it start recovery would reset endpoint DNS
settings for every other domain.
OS-resolver mode has no such restriction: it is the system resolver, so it
follows DHCP and VPN changes and shares their failure handling.
## Self-uninstall eligibility
ctrld treats a plain managed install — one Control D upstream and no endpoint
custom configuration — as eligible for the REFUSED-triggered deletion check,
which is the fast path that notices a device has been deleted server-side.
Generated `internal_*` upstreams do not count toward that, because they come
from the managed configuration itself rather than from an operator choosing a
second upstream. Eligibility is recomputed on every setup and reload, so adding
or removing Internal Domains, or gaining a genuinely custom second upstream,
is reflected instead of latching on the first value seen.
Discounting is by upstream shape, not by key name alone: an endpoint custom
configuration replaces the generated config outright and could name an upstream
`internal_0`, which must not buy eligibility. The uninstall itself remains
gated on the API confirming the device is gone.
## Refresh
`apiConfigReload` compares the stored `split_dns` against each fetch. Domains
are compared in canonical form and independently of API ordering, so an
unchanged list does not reload; resolver order is significant, because it is the
administrator's failover order. Any add, removal, domain change or resolver
change triggers a reload, which regenerates the configuration from the API
response. Because the generated configuration is rebuilt rather than patched, a
removed domain leaves no rule and no `internal_*` upstream behind.
## Logging
Generated `internal_*` upstreams carry the organization's private resolver
addresses, so anything naming one stays at debug, and the level above it carries
only a classification (`timeout`, `refused`, `unreachable`, `source_unavailable`,
`error`). That covers every consumer, not just query handling:
- the bootstrap-IP line in `setupUpstream`;
- the transport error from a failed query, which names the endpoint;
- the **loop checker**, which probes every local upstream on a one-minute
ticker — a generated resolver is always a local address, so this path runs
with no user query at all;
- the **recovery checker**, which takes every non-OS upstream. Keeping an
Internal Domain query from *starting* recovery does not keep its resolver out
of recovery started for another reason.
Info and warning logs report counts only — domains applied, OS-resolver and
explicit-resolver totals, generated resolvers, skipped and preempted entries.
Domain names and resolver addresses are organization-private and appear only at
debug level.
+30 -3
View File
@@ -56,9 +56,36 @@ type ResolverConfig struct {
CustomLastUpdate int64 `json:"custom_last_update"`
VersionTarget string `json:"version_target"`
} `json:"ctrld"`
Exclude []string `json:"exclude"`
UID string `json:"uid"`
DeactivationPin *int64 `json:"deactivation_pin,omitempty"`
Exclude []string `json:"exclude"`
SplitDNS []SplitDNS `json:"split_dns"`
UID string `json:"uid"`
DeactivationPin *int64 `json:"deactivation_pin,omitempty"`
}
// Internal Domain resolution modes. Mode is what the administrator selected;
// Resolvers is only meaningful under SplitDNSModeResolvers.
const (
// SplitDNSModeOS resolves the domain through the endpoint's own OS/default
// resolver, so it follows DHCP and VPN resolver changes.
SplitDNSModeOS = "os"
// SplitDNSModeResolvers resolves the domain through the addresses in
// Resolvers and nothing else.
SplitDNSModeResolvers = "resolvers"
)
// SplitDNS is one organization Internal Domain: a domain suffix, the resolution
// mode the organization administrator selected for it, and the resolver
// addresses that mode may need.
//
// Mode decides the routing. Resolvers is read only under
// SplitDNSModeResolvers, so addresses left behind by an earlier selection
// cannot resurrect themselves once the administrator switches back to the OS
// resolver. An empty Mode is a deployment that predates the field; the caller
// infers the mode from Resolvers in that case.
type SplitDNS struct {
Domain string `json:"domain"`
Mode string `json:"mode"`
Resolvers []string `json:"resolvers"`
}
type utilityResponse struct {
+154
View File
@@ -0,0 +1,154 @@
package controld
import (
"encoding/json"
"strings"
"testing"
)
// The API's utility response carries organization Internal Domains in
// resolver.split_dns. Parsing it must not change anything when the field is
// absent or empty, which is what every resolver without the feature returns.
func TestResolverConfigParsesSplitDNS(t *testing.T) {
for _, tc := range []struct {
name string
body string
want []SplitDNS
}{
{
name: "absent",
body: `{"success":true,"body":{"resolver":{"doh":"https://dns.controld.com/abc","uid":"abc"}}}`,
},
{
name: "null",
body: `{"success":true,"body":{"resolver":{"doh":"https://d","uid":"a","split_dns":null}}}`,
},
{
name: "empty",
body: `{"success":true,"body":{"resolver":{"doh":"https://d","uid":"a","split_dns":[]}}}`,
want: []SplitDNS{},
},
{
name: "explicit resolvers",
body: `{"success":true,"body":{"resolver":{"doh":"https://d","uid":"a","split_dns":[
{"domain":"aws.example.com","resolvers":["10.0.0.10","10.0.0.11"]}]}}}`,
want: []SplitDNS{{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}}},
},
{
name: "os resolver selection",
body: `{"success":true,"body":{"resolver":{"doh":"https://d","uid":"a","split_dns":[
{"domain":"corp.example.com","resolvers":[]},
{"domain":"lab.example.com"}]}}}`,
want: []SplitDNS{
{Domain: "corp.example.com", Resolvers: []string{}},
{Domain: "lab.example.com"},
},
},
{
name: "coexists with exclude",
body: `{"success":true,"body":{"resolver":{"doh":"https://d","uid":"a",
"exclude":["corp.example.com"],
"split_dns":[{"domain":"aws.example.com","resolvers":["10.0.0.10"]}]}}}`,
want: []SplitDNS{{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}}},
},
} {
t.Run(tc.name, func(t *testing.T) {
var resp utilityResponse
if err := json.NewDecoder(strings.NewReader(tc.body)).Decode(&resp); err != nil {
t.Fatal(err)
}
got := resp.Body.Resolver.SplitDNS
if len(got) != len(tc.want) {
t.Fatalf("split_dns = %+v, want %+v", got, tc.want)
}
for i := range got {
if got[i].Domain != tc.want[i].Domain {
t.Errorf("[%d].Domain = %q, want %q", i, got[i].Domain, tc.want[i].Domain)
}
if len(got[i].Resolvers) != len(tc.want[i].Resolvers) {
t.Fatalf("[%d].Resolvers = %v, want %v", i, got[i].Resolvers, tc.want[i].Resolvers)
}
for j := range got[i].Resolvers {
if got[i].Resolvers[j] != tc.want[i].Resolvers[j] {
t.Errorf("[%d].Resolvers[%d] = %q, want %q", i, j, got[i].Resolvers[j], tc.want[i].Resolvers[j])
}
}
}
// The rest of the model must be unaffected.
if resp.Body.Resolver.DOH == "" || resp.Body.Resolver.UID == "" {
t.Errorf("resolver = %+v", resp.Body.Resolver)
}
})
}
}
// An unknown field in an entry must not fail the whole response: the API can
// add one before ctrld knows about it.
func TestResolverConfigToleratesUnknownSplitDNSFields(t *testing.T) {
body := `{"success":true,"body":{"resolver":{"doh":"https://d","uid":"a","split_dns":[
{"domain":"aws.example.com","resolvers":["10.0.0.10"],"future_field":{"a":1}}]}}}`
var resp utilityResponse
if err := json.NewDecoder(strings.NewReader(body)).Decode(&resp); err != nil {
t.Fatal(err)
}
if len(resp.Body.Resolver.SplitDNS) != 1 || resp.Body.Resolver.SplitDNS[0].Domain != "aws.example.com" {
t.Fatalf("split_dns = %+v", resp.Body.Resolver.SplitDNS)
}
}
// The shape the API actually returns, mode field included.
func TestResolverConfigParsesSplitDNSMode(t *testing.T) {
const body = `{
"success": true,
"body": {
"resolver": {
"split_dns": [
{
"domain": "corp.example.com",
"mode": "resolvers",
"resolvers": ["10.0.0.53", "10.0.0.54"]
},
{
"domain": "office.example.com",
"mode": "os",
"resolvers": []
}
]
}
}
}`
var resp utilityResponse
if err := json.NewDecoder(strings.NewReader(body)).Decode(&resp); err != nil {
t.Fatal(err)
}
got := resp.Body.Resolver.SplitDNS
if len(got) != 2 {
t.Fatalf("split_dns = %+v", got)
}
if got[0].Domain != "corp.example.com" || got[0].Mode != SplitDNSModeResolvers {
t.Errorf("[0] = %+v", got[0])
}
if len(got[0].Resolvers) != 2 || got[0].Resolvers[0] != "10.0.0.53" || got[0].Resolvers[1] != "10.0.0.54" {
t.Errorf("[0].Resolvers = %v", got[0].Resolvers)
}
if got[1].Domain != "office.example.com" || got[1].Mode != SplitDNSModeOS {
t.Errorf("[1] = %+v", got[1])
}
if len(got[1].Resolvers) != 0 {
t.Errorf("[1].Resolvers = %v, want empty", got[1].Resolvers)
}
}
// A response without the field must still decode, so a deployment that predates
// mode keeps working.
func TestResolverConfigToleratesAbsentSplitDNSMode(t *testing.T) {
const body = `{"success":true,"body":{"resolver":{"split_dns":[
{"domain":"corp.example.com","resolvers":["10.0.0.53"]}]}}}`
var resp utilityResponse
if err := json.NewDecoder(strings.NewReader(body)).Decode(&resp); err != nil {
t.Fatal(err)
}
if got := resp.Body.Resolver.SplitDNS; len(got) != 1 || got[0].Mode != "" {
t.Fatalf("split_dns = %+v", got)
}
}