From 7da2507d19ba3f49be280230b27b9f9fc47391b6 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Tue, 15 Sep 2026 18:35:17 +0700 Subject: [PATCH] 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. --- cmd/cli/cli.go | 25 + cmd/cli/dns_proxy.go | 50 +- cmd/cli/internal_domains.go | 456 +++++++++++++++++ cmd/cli/internal_domains_review_test.go | 619 +++++++++++++++++++++++ cmd/cli/internal_domains_test.go | 632 ++++++++++++++++++++++++ cmd/cli/loop.go | 9 +- cmd/cli/prog.go | 47 +- docs/internal-domains.md | 170 +++++++ internal/controld/config.go | 33 +- internal/controld/split_dns_test.go | 154 ++++++ 10 files changed, 2175 insertions(+), 20 deletions(-) create mode 100644 cmd/cli/internal_domains.go create mode 100644 cmd/cli/internal_domains_review_test.go create mode 100644 cmd/cli/internal_domains_test.go create mode 100644 docs/internal-domains.md create mode 100644 internal/controld/split_dns_test.go diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 2c4a6a3..870d5be 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -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 { diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index 29fd440..f1318d1 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -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) diff --git a/cmd/cli/internal_domains.go b/cmd/cli/internal_domains.go new file mode 100644 index 0000000..3461c06 --- /dev/null +++ b/cmd/cli/internal_domains.go @@ -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." 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...) +} diff --git a/cmd/cli/internal_domains_review_test.go b/cmd/cli/internal_domains_review_test.go new file mode 100644 index 0000000..f26e9cb --- /dev/null +++ b/cmd/cli/internal_domains_review_test.go @@ -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") + } + }) + } +} diff --git a/cmd/cli/internal_domains_test.go b/cmd/cli/internal_domains_test.go new file mode 100644 index 0000000..f96323e --- /dev/null +++ b/cmd/cli/internal_domains_test.go @@ -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") + } +} diff --git a/cmd/cli/loop.go b/cmd/cli/loop.go index 3504bc3..e088001 100644 --- a/cmd/cli/loop.go +++ b/cmd/cli/loop.go @@ -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") diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index a838221..73f4819 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -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 } diff --git a/docs/internal-domains.md b/docs/internal-domains.md new file mode 100644 index 0000000..44caad6 --- /dev/null +++ b/docs/internal-domains.md @@ -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, `` and `*.`, +which is how ctrld matches a suffix and all of its subdomains. Explicit mode +also generates one upstream per resolver address, named `internal_`: + +```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. diff --git a/internal/controld/config.go b/internal/controld/config.go index 5efbcc4..aad670a 100644 --- a/internal/controld/config.go +++ b/internal/controld/config.go @@ -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 { diff --git a/internal/controld/split_dns_test.go b/internal/controld/split_dns_test.go new file mode 100644 index 0000000..626287a --- /dev/null +++ b/internal/controld/split_dns_test.go @@ -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) + } +}