Files
ctrld/cmd/cli/internal_domains.go
T

458 lines
17 KiB
Go

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 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 !isGeneratedInternalDomainUpstreamRef(upstream, uc) {
event.Err(err).Msgf(format, detail...)
return
}
event.Str("failure", internalDomainFailureReason(err)).
Msgf(format+" for an Internal Domain resolver", detail...)
}