Files
ctrld/cmd/cli/internal_domains.go
T
Dev Scribe b0408e4d1c cli: add network fallback and explicit-only Internal Domain modes
Internal Domains now support three modes. "os" is unchanged.
"resolvers" becomes explicit resolver with network fallback: the
configured resolvers are tried first, and a timeout, unreachable
resolver, SERVFAIL or NXDOMAIN hands the query to matching VPN DNS
servers, then domain-less VPN DNS servers, then the OS resolver's LAN
nameservers through the new LanOnlyQueryCtx. "resolvers_only" keeps
the previous strict behavior. Down fallback-mode resolvers are skipped
and re-checked in the background at most every 30s. Generated upstreams
are identified by the UpstreamConfig.InternalDomain marker instead of
the internal_ key prefix.

Master adaptations: the fallback state lives on proxyRequest and is
applied in tryUpstreams, shouldContinueWithNextUpstream and
handleAllUpstreamsFailure instead of v1.0's monolithic proxy loop.
Resolver construction uses master's context-aware NewResolver, and the
background re-check marks its context private so transport errors that
name the resolver stay at debug. Tests use master's zap JSON capture
and VPN manager logger.
2026-10-07 14:24:00 +07:00

763 lines
29 KiB
Go

package cli
import (
"context"
"errors"
"net"
"net/netip"
"slices"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/miekg/dns"
"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. It must not collide with
// the numeric key used for the Control D upstream. The key only names the
// upstream: what identifies it as generated, and in which mode, is
// UpstreamConfig.InternalDomain, which no configuration file can set.
const internalDomainUpstreamPrefix = "internal_"
// Values of UpstreamConfig.InternalDomain on a generated upstream.
const (
// internalDomainUpstreamFallback serves an Internal Domain in explicit
// resolver with network fallback mode.
internalDomainUpstreamFallback = "fallback"
// internalDomainUpstreamOnly serves an Internal Domain in explicit
// resolver only mode: its answer or failure is final.
internalDomainUpstreamOnly = "only"
)
// internalDomainUpstreamName is the Name given to every generated Internal
// Domain upstream. It is only a display label: what identifies an upstream as
// generated is UpstreamConfig.InternalDomain, which no configuration can set.
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
only int // explicit domains with no network fallback
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
}
// internalDomainResolution is how an Internal Domain is resolved.
type internalDomainResolution int
const (
// internalDomainViaOS is the "network default" mode: the endpoint's
// OS/default resolver.
internalDomainViaOS internalDomainResolution = iota
// internalDomainViaResolvers is the configured resolvers first, then the
// active network and VPN resolvers. The default explicit selection.
internalDomainViaResolvers
// internalDomainViaResolversOnly is the configured resolvers and nothing
// else.
internalDomainViaResolversOnly
)
// internalDomainMode reports how entry is resolved, 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;
// the explicit modes stay explicit even when the list they point 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,
// and an explicit list gets the default explicit mode.
//
// An unrecognized mode is not guessed at: routing a private domain by guess is
// the disclosure this feature exists to prevent. It fails closed instead. With
// resolvers it is resolved as explicit resolver only, through the resolvers the
// administrator chose and nothing else, so a wire value this build does not
// know, such as a renamed strict mode, cannot send the domain to the Control D
// upstream or the network. Without resolvers there is nothing to fail closed
// to, so the entry is reported as unknown and dropped.
func internalDomainMode(entry controld.SplitDNS) (resolution internalDomainResolution, known bool) {
switch strings.ToLower(strings.TrimSpace(entry.Mode)) {
case controld.SplitDNSModeOS:
return internalDomainViaOS, true
case controld.SplitDNSModeResolvers:
return internalDomainViaResolvers, true
case controld.SplitDNSModeResolversOnly:
return internalDomainViaResolversOnly, true
case "":
if len(entry.Resolvers) > 0 {
return internalDomainViaResolvers, true
}
return internalDomainViaOS, true
default:
if len(entry.Resolvers) > 0 {
return internalDomainViaResolversOnly, true
}
return internalDomainViaOS, false
}
}
// internalDomainModeRecognized reports whether mode is one this build knows,
// as opposed to one internalDomainMode resolves by failing closed.
func internalDomainModeRecognized(mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "", controld.SplitDNSModeOS, controld.SplitDNSModeResolvers, controld.SplitDNSModeResolversOnly:
return true
}
return false
}
// internalDomainRouting returns how an entry is resolved and the endpoints it
// 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) (resolution internalDomainResolution, endpoints []string, reason string) {
resolution, known := internalDomainMode(entry)
if !known {
return resolution, nil, "unknown_mode"
}
if resolution == internalDomainViaOS {
return resolution, 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 resolution, nil, "no_usable_resolver"
}
return resolution, 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. Those upstreams carry the mode in
// UpstreamConfig.InternalDomain.
//
// 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.
resolution, endpoints, reason := internalDomainRouting(entry)
if reason != "" {
mainLog.Load().Debug().Msgf("internal domains: skipping %q: %s", domain, reason)
summary.skipped++
continue
}
if !internalDomainModeRecognized(entry.Mode) {
mainLog.Load().Debug().Msgf("internal domains: %q has unrecognized mode %q; using its resolvers only", domain, entry.Mode)
}
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
}
marker := internalDomainUpstreamFallback
if resolution == internalDomainViaResolversOnly {
marker = internalDomainUpstreamOnly
}
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,
InternalDomain: marker,
}
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++
if resolution == internalDomainViaResolversOnly {
summary.only++
}
mainLog.Load().Debug().Msgf("internal domains: %q routed to %v", domain, targets)
}
}
return summary
}
// isGeneratedInternalDomainUpstream reports whether uc is an upstream
// 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. Neither the key nor any other configurable field is proof of
// that: a local config or an endpoint custom configuration can name an upstream
// anything and give it any type and name. Only the InternalDomain marker is,
// because ctrld sets it and no configuration file can.
func isGeneratedInternalDomainUpstream(uc *ctrld.UpstreamConfig) bool {
return uc != nil && uc.InternalDomain != ""
}
// internalDomainUpstream returns the generated Internal Domain upstream an
// "upstream.<key>" reference names, or nil if it names any other upstream.
func (p *prog) internalDomainUpstream(upstream string) *ctrld.UpstreamConfig {
if p.cfg == nil {
return nil
}
uc := p.cfg.Upstream[strings.TrimPrefix(upstream, upstreamPrefix)]
if !isGeneratedInternalDomainUpstream(uc) {
return nil
}
return uc
}
// isInternalDomainUpstream reports whether an upstream reference names a
// generated Internal Domain resolver, in either explicit mode.
func (p *prog) isInternalDomainUpstream(upstream string) bool {
return p.internalDomainUpstream(upstream) != nil
}
// internalDomainExplicitUpstreams reports whether every upstream serving a
// query is a configured Internal Domain resolver, in either explicit mode.
//
// proxy() uses this to try the configured resolvers before anything else, and
// to keep such a query off the OS-resolver catch-all when they fail: that
// catch-all may reach public nameservers, and a private name must not be sent
// somewhere it was not meant to go. In fallback mode proxy() tries the
// network resolvers instead (see internalDomainFallbackUpstreams). 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 (p *prog) internalDomainExplicitUpstreams(upstreams []string) bool {
if len(upstreams) == 0 {
return false
}
for _, upstream := range upstreams {
if !p.isInternalDomainUpstream(upstream) {
return false
}
}
return true
}
// internalDomainFallbackUpstreams reports whether upstreams are the configured
// resolvers of an Internal Domain in explicit-with-network-fallback mode, whose
// failure, SERVFAIL or NXDOMAIN hands the query to the active network and VPN
// resolvers.
func (p *prog) internalDomainFallbackUpstreams(upstreams []string) bool {
if len(upstreams) == 0 {
return false
}
for _, upstream := range upstreams {
uc := p.internalDomainUpstream(upstream)
if uc == nil || uc.InternalDomain != internalDomainUpstreamFallback {
return false
}
}
return true
}
// internalDomainFallbackRcode reports whether a configured resolver's answer
// sends a fallback-mode query on to the next resolver. SERVFAIL means the
// resolver could not answer, and NXDOMAIN may only mean it does not know a
// name the local network's resolver does. REFUSED and NOTIMP mean it will not
// serve this endpoint, as an organization resolver with an access list answers
// an endpoint off the organization network, which is closer to unreachable
// than to an answer. Any other answer is final, including an empty NOERROR:
// the name exists, it just has no record of that type.
func internalDomainFallbackRcode(rcode int) bool {
switch rcode {
case dns.RcodeServerFailure, dns.RcodeNameError, dns.RcodeRefused, dns.RcodeNotImplemented:
return true
}
return false
}
// 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. The mode
// is significant too, including a switch between the two explicit modes.
func internalDomainsEqual(a, b []controld.SplitDNS) bool {
type route struct {
domain string
resolution internalDomainResolution
endpoints []string
}
canonical := func(entries []controld.SplitDNS) []route {
out := make([]route, 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.
resolution, endpoints, reason := internalDomainRouting(entry)
if reason != "" {
continue
}
out = append(out, route{domain: domain, resolution: resolution, endpoints: 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 || x[i].resolution != y[i].resolution || !slices.Equal(x[i].endpoints, y[i].endpoints) {
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"
}
// logUpstreamProbeFailure reports a background probe failure against the
// upstream that the reference upstream names.
//
// 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. The name and the
// endpoint of any upstream are operator text that can hold a token, so both
// stay on the debug line. The leveled line names the upstream by its bounded
// name, so the outcome stays visible while the operator text does not. A
// generated Internal Domain resolver also trades the address-bearing error
// for a classification.
func (p *prog) logUpstreamProbeFailure(upstream string, uc *ctrld.UpstreamConfig, err error, level func() *ctrld.LogEvent, format string, args ...any) {
detail := append([]any{}, args...)
p.Debug().Err(err).Msgf(format+" for upstream: %q, endpoint: %q", append(detail, uc.Name, uc.Endpoint)...)
event := level().Str("upstream", journalUpstreamName(upstream))
if !isGeneratedInternalDomainUpstream(uc) {
event.Err(err).Msgf(format, detail...)
return
}
event.Str("failure", internalDomainFailureReason(err)).
Msgf(format+" for an Internal Domain resolver", detail...)
}
// betterInternalDomainNegative returns whichever of two negative answers a
// fallback-mode query should end with: NXDOMAIN over SERVFAIL, since a
// resolver that says the name does not exist has answered, and otherwise the
// first one seen.
func betterInternalDomainNegative(current, candidate *dns.Msg) *dns.Msg {
switch {
case candidate == nil:
return current
case current == nil:
return candidate
case candidate.Rcode == dns.RcodeNameError && current.Rcode != dns.RcodeNameError:
return candidate
}
return current
}
// internalDomainVPNUpstream returns the upstream the network fallback uses for
// a VPN DNS server. A seam: VPN DNS servers are dialed on port 53, which a test
// fixture cannot listen on.
var internalDomainVPNUpstream = func(m *vpnDNSManager, server string) *ctrld.UpstreamConfig {
return m.upstreamConfigFor(server)
}
// internalDomainOSResolve resolves msg through the OS resolver for the network
// fallback. A seam, so a test sees the context the fallback passes: that
// context is what keeps the private name off public nameservers.
var internalDomainOSResolve = func(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
resolver, err := ctrld.NewResolver(ctx, osUpstreamConfig)
if err != nil {
return nil, err
}
resolveCtx, cancel := osUpstreamConfig.Context(ctx)
defer cancel()
return resolver.Resolve(resolveCtx, msg)
}
// resolveInternalDomainOnNetwork is the network fallback of an Internal Domain
// in explicit-with-network-fallback mode. proxy() runs it once the configured
// resolvers have failed, or answered SERVFAIL or NXDOMAIN.
//
// It asks only resolvers that the endpoint's active network or VPN provides,
// in this order: VPN DNS servers whose domains match the query, then VPN DNS
// servers with no domains (both only in dns-intercept mode, where ctrld tracks
// them), then the OS resolver's LAN nameservers. The OS resolver is asked as a
// LAN-only query, so the private name never reaches a public nameserver, not
// even one DHCP supplied, nor ctrld's own public fallback. With no LAN
// nameserver the OS step sends nothing.
//
// The first NOERROR answer is returned as answer, including an empty one,
// which is final. Otherwise negative is the best SERVFAIL or NXDOMAIN answer
// seen. While Windows is serving retained VPN DNS state, a VPN transport
// failure stops the fallback before the OS resolver, as it does for VPN split
// routing.
func (p *prog) resolveInternalDomainOnNetwork(ctx context.Context, msg *dns.Msg) (answer, negative *dns.Msg) {
domain := msg.Question[0].Name
resolve := func(ctx context.Context, uc *ctrld.UpstreamConfig) (*dns.Msg, error) {
resolver, err := ctrld.NewResolver(ctx, uc)
if err != nil {
return nil, err
}
resolveCtx, cancel := uc.Context(ctx)
defer cancel()
return resolver.Resolve(resolveCtx, msg)
}
// accept reports whether a network answer is final, and keeps a negative
// one for the end.
accept := func(source string, m *dns.Msg) bool {
if !sameQuestion(msg, m) {
ctrld.Log(ctx, p.Debug(), "internal domain fallback: discarding answer from %s: question mismatch", source)
return false
}
if m.Rcode == dns.RcodeSuccess {
ctrld.Log(ctx, p.Debug(), "internal domain fallback: %s answered %s", source, domain)
return true
}
ctrld.Log(ctx, p.Debug(), "internal domain fallback: %s answered %s for %s",
source, dns.RcodeToString[m.Rcode], domain)
if internalDomainFallbackRcode(m.Rcode) {
negative = betterInternalDomainNegative(negative, m)
}
return false
}
if dnsIntercept && p.vpnDNS != nil {
var servers []string
for _, server := range slices.Concat(p.vpnDNS.UpstreamForDomain(domain), p.vpnDNS.DomainlessServers()) {
if !slices.Contains(servers, server) {
servers = append(servers, server)
}
}
var gotAnswer, gotTransportFailure bool
for _, server := range servers {
m, err := resolve(ctx, internalDomainVPNUpstream(p.vpnDNS, server))
if m == nil {
gotTransportFailure = true
ctrld.Log(ctx, p.Debug().Err(err), "internal domain fallback: VPN DNS server %s failed", server)
continue
}
gotAnswer = true
p.vpnDNS.VPNDNSReachable()
if accept("VPN DNS server "+server, m) {
return m, nil
}
}
if !gotAnswer && gotTransportFailure && p.vpnDNS.ShouldFailClosedAfterVPNDNSTransportFailure(domain, servers) {
return nil, negative
}
}
m, err := internalDomainOSResolve(ctrld.LanOnlyQueryCtx(ctx), msg)
if m == nil {
ctrld.Log(ctx, p.Debug().Err(err), "internal domain fallback: OS resolver LAN nameservers failed")
return nil, negative
}
if accept("OS resolver", m) {
return m, nil
}
return nil, negative
}
// internalDomainProbeInterval is how often, at most, a down fallback-mode
// Internal Domain resolver is re-checked in the background.
const internalDomainProbeInterval = 30 * time.Second
// internalDomainProber re-checks down fallback-mode Internal Domain resolvers
// in the background, and marks one up again when it answers.
//
// proxy() skips such a resolver while it is down, so that an endpoint away
// from the organization network goes to the network fallback without waiting
// on it. A generated resolver comes back up only by answering a query: it is
// excluded from recovery, and the loop checker leaves down upstreams alone. So
// without this re-check, a resolver that went down would stay skipped until
// the next reload, even once the endpoint is back on the organization network.
//
// The zero value is ready to use.
type internalDomainProber struct {
mu sync.Mutex
last map[string]time.Time
// now and start are seams, so a test controls the clock and runs the
// re-check itself. nil means time.Now and a new goroutine.
now func() time.Time
start func(func())
}
// probe re-checks upstream in the background, at most once per
// internalDomainProbeInterval, by sending it a copy of msg. The query goes to
// the organization's own resolver, which it was meant for anyway. Any DNS
// answer proves the resolver reachable, the same as on the query path, and
// marks it up in um; a failure leaves it down until the next re-check.
func (pr *internalDomainProber) probe(um *upstreamMonitor, upstream string, uc *ctrld.UpstreamConfig, msg *dns.Msg) {
now := time.Now
if pr.now != nil {
now = pr.now
}
pr.mu.Lock()
if last, ok := pr.last[upstream]; ok && now().Sub(last) < internalDomainProbeInterval {
pr.mu.Unlock()
return
}
if pr.last == nil {
pr.last = make(map[string]time.Time)
}
pr.last[upstream] = now()
pr.mu.Unlock()
query := msg.Copy()
run := func() {
var answer *dns.Msg
// The resolver address is private: keep transport errors that name it
// at debug, as the query path does.
ctx := ctrld.PrivateResolverCtx(ctrld.LoggerCtx(context.Background(), mainLog.Load()))
resolver, err := ctrld.NewResolver(ctx, uc)
if err == nil {
resolveCtx, cancel := uc.Context(ctx)
answer, err = resolver.Resolve(resolveCtx, query)
cancel()
}
if answer == nil {
mainLog.Load().Debug().Err(err).Msgf("internal domains: %s is still down", upstream)
return
}
mainLog.Load().Debug().Msgf("internal domains: %s answered the re-check", upstream)
um.noteSuccess(upstream)
}
if pr.start != nil {
pr.start(run)
return
}
go run()
}