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