mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
Internal Domains now support three modes. "os" (network default) is unchanged. "resolvers" becomes explicit resolver with network fallback, the default explicit selection: the configured resolvers are tried first, and a timeout, unreachable resolver, SERVFAIL, NXDOMAIN, REFUSED or NOTIMP hands the query to the network resolvers - matching VPN DNS servers, then domain-less VPN DNS servers, then the OS resolver's LAN nameservers. A valid empty answer stays final. "resolvers_only" keeps the previous strict behavior. The OS step uses a new LanOnlyQueryCtx: the OS resolver then asks only its LAN-classified nameservers (private, loopback, link-local, CGNAT) and sends nothing when it has none, so the private name never reaches a public nameserver, whether DHCP supplied it or it is ctrld's own public fallback. LanQueryCtx only dropped the latter. LAN-only queries get their own singleflight and hot-cache key, so they are never answered with an ordinary query's public answer. In fallback mode a configured resolver that the upstream monitor has marked down is skipped instead of waited on, so an endpoint off the organization network reaches the fallback without paying the resolver's 2s timeout on every query. Nothing else marks a generated resolver up again, so skipping one re-checks it in the background, at most once every 30s; an answer marks it up. Strict mode never skips. An absent mode with resolvers gets the fallback mode. An unrecognized mode with resolvers fails closed to explicit resolver only, so a wire value this build does not know never sends the domain to the Control D upstream; without resolvers it is dropped. Switching between the explicit modes is detected on refresh. Generated upstreams are now identified by an UpstreamConfig.InternalDomain marker that records their mode and that no configuration file can set, instead of by the internal_ key prefix. An upstream a local ctrld.toml or custom config defines as internal_foo, or as a copy of a generated one, is an ordinary upstream again: it keeps the OS-resolver catch-all, recovery and query-health grading. The "resolvers_only" wire value is provisional until the API agrees on it; the dashboard does not offer strict mode yet.
647 lines
24 KiB
Go
647 lines
24 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/miekg/dns"
|
|
"github.com/rs/zerolog"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"github.com/Control-D-Inc/ctrld/internal/controld"
|
|
)
|
|
|
|
func internalDomainsTestConfig() *ctrld.Config {
|
|
return &ctrld.Config{
|
|
Listener: map[string]*ctrld.ListenerConfig{"0": {Policy: &ctrld.ListenerPolicyConfig{Name: "My Policy"}}},
|
|
Upstream: map[string]*ctrld.UpstreamConfig{"0": {Endpoint: "https://dns.controld.com/test", Type: ctrld.ResolverTypeDOH}},
|
|
Network: map[string]*ctrld.NetworkConfig{"0": {Name: "Network 0", Cidrs: []string{"0.0.0.0/0"}}},
|
|
}
|
|
}
|
|
|
|
// ruleTargets returns the targets of the rule for source, and whether it exists.
|
|
func ruleTargets(t *testing.T, cfg *ctrld.Config, source string) ([]string, bool) {
|
|
t.Helper()
|
|
for _, rule := range cfg.Listener["0"].Policy.Rules {
|
|
if targets, ok := rule[source]; ok {
|
|
return targets, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func TestNormalizeInternalDomain(t *testing.T) {
|
|
long63 := strings.Repeat("a", 63)
|
|
for _, tc := range []struct{ name, in, want string }{
|
|
// Canonicalization that cannot change which names match.
|
|
{"plain", "aws.example.com", "aws.example.com"},
|
|
{"case and root dot", " AWS.Example.COM. ", "aws.example.com"},
|
|
{"leading wildcard", "*.aws.example.com", "aws.example.com"},
|
|
{"surrounding control whitespace", "\r\nCORP.EXAMPLE.\r\n", "corp.example"},
|
|
{"single label", "intranet", "intranet"},
|
|
{"punycode", "xn--80ak6aa92e.com", "xn--80ak6aa92e.com"},
|
|
{"digits and hyphens", "dc-01.site2.example.com", "dc-01.site2.example.com"},
|
|
{"max label", long63 + ".example.com", long63 + ".example.com"},
|
|
|
|
// Rejected: an empty or absent name.
|
|
{"empty", "", ""},
|
|
{"whitespace only", " ", ""},
|
|
{"root only", ".", ""},
|
|
{"wildcard only", "*.", ""},
|
|
|
|
// Rejected: empty labels. A leading dot is not repaired, because the
|
|
// repaired value is a different suffix from the configured one.
|
|
{"leading dot", ".aws.example.com.", ""},
|
|
{"double dot", "aws..example.com", ""},
|
|
{"trailing double dot", "aws.example.com..", ""},
|
|
|
|
// Rejected: characters outside the ASCII hostname contract. A denylist
|
|
// would have to name every one of these; the positive rule does not.
|
|
{"space", "has space.com", ""},
|
|
{"embedded wildcard", "we*rd.com", ""},
|
|
{"slash", "a/b.com", ""},
|
|
{"newline", "aws\nevil.example.com", ""},
|
|
{"carriage return", "aws\revil.example.com", ""},
|
|
{"nul", "aws\x00evil.example.com", ""},
|
|
{"escape", "aws\x1bevil.example.com", ""},
|
|
{"delete", "aws\x7fevil.example.com", ""},
|
|
{"quote", "aws\"evil.example.com", ""},
|
|
{"colon", "aws:53.example.com", ""},
|
|
{"underscore", "_msdcs.example.com", ""},
|
|
{"non-ascii", "exämple.com", ""},
|
|
|
|
// Rejected: hyphen placement and length.
|
|
{"leading hyphen", "-aws.example.com", ""},
|
|
{"trailing hyphen", "aws-.example.com", ""},
|
|
{"label too long", strings.Repeat("a", 64) + ".example.com", ""},
|
|
{"name too long", strings.Repeat("a.", 127) + "example.com", ""},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := normalizeInternalDomain(tc.in); got != tc.want {
|
|
t.Errorf("normalizeInternalDomain(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A rejected domain is counted and produces no rule, rather than being repaired
|
|
// into a suffix nobody configured.
|
|
func TestApplyInternalDomainsRejectsMalformedDomains(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws\nevil.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}},
|
|
{Domain: "ok.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.54"}},
|
|
})
|
|
if summary.domains != 1 || summary.skipped != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
for _, rule := range cfg.Listener["0"].Policy.Rules {
|
|
for source := range rule {
|
|
if strings.ContainsAny(source, "\n\r\x00") {
|
|
t.Errorf("control character reached a policy rule: %q", source)
|
|
}
|
|
if source != "ok.example.com" && source != "*.ok.example.com" {
|
|
t.Errorf("unexpected rule source %q", source)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInternalDomainEndpoint(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
in string
|
|
want string
|
|
ok bool
|
|
}{
|
|
{"10.0.0.10", "10.0.0.10:53", true},
|
|
{" 10.0.0.11 ", "10.0.0.11:53", true},
|
|
{"2001:db8::1", "[2001:db8::1]:53", true},
|
|
{"10.0.0.10:5353", "10.0.0.10:5353", true},
|
|
{"[2001:db8::1]:5353", "[2001:db8::1]:5353", true},
|
|
{"dns.internal.example.com", "", false},
|
|
{"10.0.0.256", "", false},
|
|
{"", "", false},
|
|
{"10.0.0.10:0", "", false},
|
|
{"10.0.0.10:70000", "", false},
|
|
{"10.0.0.10:dns", "", false},
|
|
} {
|
|
got, ok := internalDomainEndpoint(tc.in)
|
|
if got != tc.want || ok != tc.ok {
|
|
t.Errorf("internalDomainEndpoint(%q) = %q, %v; want %q, %v", tc.in, got, ok, tc.want, tc.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyInternalDomainsOSResolverMode(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{{Domain: "corp.example.com"}})
|
|
|
|
if summary.domains != 1 || summary.osMode != 1 || summary.explicit != 0 || summary.resolvers != 0 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
for _, source := range []string{"corp.example.com", "*.corp.example.com"} {
|
|
targets, ok := ruleTargets(t, cfg, source)
|
|
if !ok {
|
|
t.Fatalf("no rule for %q", source)
|
|
}
|
|
if len(targets) != 0 {
|
|
t.Errorf("rule %q targets = %v, want empty (OS resolver)", source, targets)
|
|
}
|
|
}
|
|
if len(cfg.Upstream) != 1 {
|
|
t.Errorf("upstreams = %v, want only the Control D upstream", cfg.Upstream)
|
|
}
|
|
}
|
|
|
|
func TestApplyInternalDomainsExplicitResolverMode(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "2001:db8::11"}},
|
|
})
|
|
|
|
if summary.domains != 1 || summary.explicit != 1 || summary.osMode != 0 || summary.resolvers != 2 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
want := []string{"upstream.internal_0", "upstream.internal_1"}
|
|
for _, source := range []string{"aws.example.com", "*.aws.example.com"} {
|
|
targets, ok := ruleTargets(t, cfg, source)
|
|
if !ok {
|
|
t.Fatalf("no rule for %q", source)
|
|
}
|
|
if strings.Join(targets, ",") != strings.Join(want, ",") {
|
|
t.Errorf("rule %q targets = %v, want %v (administrator order)", source, targets, want)
|
|
}
|
|
}
|
|
if got := cfg.Upstream["internal_0"]; got == nil || got.Endpoint != "10.0.0.10:53" || got.Type != ctrld.ResolverTypeLegacy {
|
|
t.Errorf("internal_0 = %+v", got)
|
|
}
|
|
if got := cfg.Upstream["internal_1"]; got == nil || got.Endpoint != "[2001:db8::11]:53" {
|
|
t.Errorf("internal_1 = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyInternalDomainsMalformedResolvers(t *testing.T) {
|
|
t.Run("drops only the malformed address", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"not-an-ip", "10.0.0.10"}},
|
|
})
|
|
if summary.domains != 1 || summary.resolvers != 1 || summary.skipped != 0 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
targets, _ := ruleTargets(t, cfg, "aws.example.com")
|
|
if len(targets) != 1 || targets[0] != "upstream.internal_0" {
|
|
t.Errorf("targets = %v", targets)
|
|
}
|
|
})
|
|
|
|
t.Run("drops the entry when no address is usable", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"not-an-ip", ""}},
|
|
})
|
|
if summary.domains != 0 || summary.skipped != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
// Falling back to the OS resolver here would silently replace the
|
|
// administrator's explicit selection with a different one.
|
|
if _, ok := ruleTargets(t, cfg, "aws.example.com"); ok {
|
|
t.Error("entry with no usable resolver must not produce a rule")
|
|
}
|
|
if len(cfg.Upstream) != 1 {
|
|
t.Errorf("upstreams = %v, want no generated upstream", cfg.Upstream)
|
|
}
|
|
})
|
|
|
|
t.Run("drops an unusable domain", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{{Domain: " ", Resolvers: []string{"10.0.0.10"}}})
|
|
if summary.domains != 0 || summary.skipped != 1 || len(cfg.Upstream) != 1 {
|
|
t.Fatalf("summary = %+v, upstreams = %v", summary, cfg.Upstream)
|
|
}
|
|
})
|
|
|
|
t.Run("drops a duplicate domain", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
|
|
{Domain: "AWS.Example.com.", Resolvers: []string{"10.0.0.99"}},
|
|
})
|
|
if summary.domains != 1 || summary.skipped != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
targets, _ := ruleTargets(t, cfg, "aws.example.com")
|
|
if len(targets) != 1 || cfg.Upstream["internal_0"].Endpoint != "10.0.0.10:53" {
|
|
t.Errorf("first entry must win: targets = %v", targets)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Magic Folder excludes and endpoint custom configuration are written to the
|
|
// policy before Internal Domains are applied, and must keep their routing.
|
|
func TestApplyInternalDomainsPrecedence(t *testing.T) {
|
|
t.Run("an existing rule is never overwritten", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
cfg.Listener["0"].Policy.Rules = []ctrld.Rule{{"aws.example.com": []string{}}}
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
|
|
})
|
|
targets, _ := ruleTargets(t, cfg, "aws.example.com")
|
|
if len(targets) != 0 {
|
|
t.Errorf("exclude rule was overwritten: targets = %v", targets)
|
|
}
|
|
// The wildcard was still free, so the domain is only partly preempted.
|
|
if wildcard, ok := ruleTargets(t, cfg, "*.aws.example.com"); !ok || len(wildcard) != 1 {
|
|
t.Errorf("wildcard rule = %v, %v", wildcard, ok)
|
|
}
|
|
if summary.domains != 1 {
|
|
t.Errorf("summary = %+v", summary)
|
|
}
|
|
})
|
|
|
|
t.Run("a fully covered domain is reported as preempted", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
cfg.Listener["0"].Policy.Rules = []ctrld.Rule{
|
|
{"aws.example.com": []string{"upstream.0"}},
|
|
{"*.aws.example.com": []string{"upstream.0"}},
|
|
}
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
|
|
})
|
|
if summary.domains != 0 || summary.preempted != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
if len(cfg.Listener["0"].Policy.Rules) != 2 {
|
|
t.Errorf("rules = %v, want no addition", cfg.Listener["0"].Policy.Rules)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A configured suffix must route itself and its subdomains, and nothing else.
|
|
func TestApplyInternalDomainsSuffixMatching(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
|
|
{Domain: "corp.example.com"},
|
|
})
|
|
p := &prog{cfg: cfg}
|
|
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
domain string
|
|
want []string
|
|
}{
|
|
{"aws.example.com", []string{"upstream.internal_0"}},
|
|
{"host.aws.example.com", []string{"upstream.internal_0"}},
|
|
{"a.b.c.aws.example.com", []string{"upstream.internal_0"}},
|
|
{"corp.example.com", []string{}},
|
|
{"host.corp.example.com", []string{}},
|
|
{"example.com", []string{"upstream.0"}},
|
|
{"notaws.example.com", []string{"upstream.0"}},
|
|
{"aws.example.com.evil.test", []string{"upstream.0"}},
|
|
} {
|
|
ufr := p.upstreamFor(context.Background(), "0", cfg.Listener["0"], addr, "", tc.domain)
|
|
if strings.Join(ufr.upstreams, ",") != strings.Join(tc.want, ",") {
|
|
t.Errorf("upstreamFor(%q) = %v, want %v", tc.domain, ufr.upstreams, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A refresh rebuilds the generated config, so a removal must leave no rule and
|
|
// no upstream behind.
|
|
func TestApplyInternalDomainsAuthoritativeReplacement(t *testing.T) {
|
|
first := internalDomainsTestConfig()
|
|
applyInternalDomains(first, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
|
|
{Domain: "old.example.com", Resolvers: []string{"10.0.0.20"}},
|
|
})
|
|
if len(first.Upstream) != 3 {
|
|
t.Fatalf("upstreams = %v", first.Upstream)
|
|
}
|
|
|
|
// The refresh path regenerates cfg from the API response rather than
|
|
// mutating the previous one.
|
|
second := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(second, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.99"}},
|
|
})
|
|
if summary.domains != 1 || summary.resolvers != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
if _, ok := ruleTargets(t, second, "old.example.com"); ok {
|
|
t.Error("removed domain left a stale rule")
|
|
}
|
|
if len(second.Upstream) != 2 {
|
|
t.Errorf("upstreams = %v, want the Control D upstream plus one internal", second.Upstream)
|
|
}
|
|
if second.Upstream["internal_0"].Endpoint != "10.0.0.99:53" {
|
|
t.Errorf("resolver change not applied: %+v", second.Upstream["internal_0"])
|
|
}
|
|
}
|
|
|
|
func TestApplyInternalDomainsEmpty(t *testing.T) {
|
|
for _, entries := range [][]controld.SplitDNS{nil, {}} {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, entries)
|
|
if !summary.empty() {
|
|
t.Errorf("summary = %+v, want empty", summary)
|
|
}
|
|
if len(cfg.Listener["0"].Policy.Rules) != 0 || len(cfg.Upstream) != 1 {
|
|
t.Errorf("absent split_dns changed the config: rules=%v upstreams=%v",
|
|
cfg.Listener["0"].Policy.Rules, cfg.Upstream)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInternalDomainsEqual(t *testing.T) {
|
|
base := []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
|
|
{Domain: "corp.example.com"},
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
next []controld.SplitDNS
|
|
want bool
|
|
}{
|
|
{"unchanged", base, true},
|
|
{"reordered domains", []controld.SplitDNS{
|
|
{Domain: "corp.example.com"},
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
|
|
}, true},
|
|
{"equivalent spelling", []controld.SplitDNS{
|
|
{Domain: "AWS.Example.com.", Resolvers: []string{" 10.0.0.10 ", "10.0.0.11:53"}},
|
|
{Domain: "*.corp.example.com"},
|
|
}, true},
|
|
{"resolver order changed", []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.11", "10.0.0.10"}},
|
|
{Domain: "corp.example.com"},
|
|
}, false},
|
|
{"resolver changed", []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.99"}},
|
|
{Domain: "corp.example.com"},
|
|
}, false},
|
|
{"domain removed", base[:1], false},
|
|
{"domain added", append(append([]controld.SplitDNS{}, base...), controld.SplitDNS{Domain: "new.example.com"}), false},
|
|
{"mode changed to explicit", []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
|
|
{Domain: "corp.example.com", Resolvers: []string{"10.0.0.30"}},
|
|
}, false},
|
|
{"emptied", nil, false},
|
|
} {
|
|
if got := internalDomainsEqual(base, tc.next); got != tc.want {
|
|
t.Errorf("%s: internalDomainsEqual = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
if !internalDomainsEqual(nil, []controld.SplitDNS{}) {
|
|
t.Error("nil and empty lists must compare equal")
|
|
}
|
|
}
|
|
|
|
func TestInternalDomainExplicitUpstreams(t *testing.T) {
|
|
p := internalDomainUpstreamsProg(map[string]string{
|
|
"internal_0": internalDomainUpstreamFallback,
|
|
"internal_1": internalDomainUpstreamOnly,
|
|
"0": "",
|
|
})
|
|
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 := p.internalDomainExplicitUpstreams(tc.in); got != tc.want {
|
|
t.Errorf("internalDomainExplicitUpstreams(%v) = %v, want %v", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Info and warning logs must not carry organization domain names or resolver
|
|
// addresses; those stay at debug level.
|
|
func TestInternalDomainsSummaryLogsCountsOnly(t *testing.T) {
|
|
var buf strings.Builder
|
|
logger := zerolog.New(&buf)
|
|
logInternalDomainsSummary(logger, internalDomainsSummary{
|
|
domains: 2, osMode: 1, explicit: 1, resolvers: 3, skipped: 1,
|
|
})
|
|
out := buf.String()
|
|
for _, want := range []string{`"domains":2`, `"os_resolver":1`, `"explicit_resolver":1`, `"resolvers":3`, `"skipped":1`} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("log %q missing %q", out, want)
|
|
}
|
|
}
|
|
if !strings.Contains(out, `"level":"warn"`) {
|
|
t.Errorf("a summary with skipped entries must warn: %q", out)
|
|
}
|
|
|
|
buf.Reset()
|
|
logInternalDomainsSummary(logger, internalDomainsSummary{domains: 1, osMode: 1})
|
|
if !strings.Contains(buf.String(), `"level":"info"`) {
|
|
t.Errorf("a clean summary must be info: %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// An explicit-resolver Internal Domain must not be handed to VPN DNS split
|
|
// routing: the administrator named those resolvers, and VPN suffixes are
|
|
// auto-detected. In "explicit resolver only" mode, when the named resolvers
|
|
// are unreachable the query fails instead of reaching the OS resolver.
|
|
func TestInternalDomainsBeatVPNSplitRoutingAndDoNotLeak(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(true)
|
|
applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Mode: controld.SplitDNSModeResolversOnly, Resolvers: []string{"127.0.0.1:1"}},
|
|
})
|
|
|
|
p := &prog{cfg: cfg}
|
|
p.um = newUpstreamMonitor(p.cfg)
|
|
p.lanLoopGuard = newLoopGuard()
|
|
p.ptrLoopGuard = newLoopGuard()
|
|
p.vpnDNS = &vpnDNSManager{routes: map[string][]string{"example.com": {"127.0.0.1:1"}}}
|
|
|
|
prevIntercept := dnsIntercept
|
|
dnsIntercept = true
|
|
t.Cleanup(func() { dnsIntercept = prevIntercept })
|
|
|
|
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
|
|
ufr := p.upstreamFor(ctx, "0", cfg.Listener["0"], addr, "", "host.aws.example.com")
|
|
if !p.internalDomainExplicitUpstreams(ufr.upstreams) {
|
|
t.Fatalf("upstreams = %v, want only internal resolvers", ufr.upstreams)
|
|
}
|
|
|
|
// logOutput accumulates for the whole test binary; only this call's tail
|
|
// describes what proxy() did here.
|
|
before := len(logOutput.String())
|
|
// A trailing dot: without it the name is not a valid wire question, and the
|
|
// failure would prove nothing about whether a resolver was contacted.
|
|
res := p.proxy(ctx, &proxyRequest{msg: newDnsMsgWithHostname("host.aws.example.com.", dns.TypeA), ufr: ufr})
|
|
out := logOutput.String()[before:]
|
|
|
|
if strings.Contains(out, "VPN DNS route matched") {
|
|
t.Errorf("VPN split routing overrode an explicit Internal Domain: %s", out)
|
|
}
|
|
if strings.Contains(out, "attempting query to OS resolver as a retry catch all") {
|
|
t.Errorf("unreachable internal resolvers leaked to the OS resolver: %s", out)
|
|
}
|
|
if res == nil || res.answer == nil || res.answer.Rcode != dns.RcodeServerFailure {
|
|
t.Errorf("answer = %v, want SERVFAIL", res)
|
|
}
|
|
}
|
|
|
|
// A domain that produces no rule must produce no upstream either, or the
|
|
// generated config would carry an internal_* upstream nothing refers to.
|
|
func TestApplyInternalDomainsLeaveNoOrphanUpstreams(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
cfg.Listener["0"].Policy.Rules = []ctrld.Rule{
|
|
{"aws.example.com": []string{"upstream.0"}},
|
|
{"*.aws.example.com": []string{"upstream.0"}},
|
|
}
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
|
|
{Domain: "ok.example.com", Resolvers: []string{"10.0.0.20"}},
|
|
})
|
|
if summary.preempted != 1 || summary.domains != 1 || summary.resolvers != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
for key, uc := range cfg.Upstream {
|
|
if !strings.HasPrefix(key, internalDomainUpstreamPrefix) {
|
|
continue
|
|
}
|
|
if uc.Endpoint != "10.0.0.20:53" {
|
|
t.Errorf("orphan upstream %s = %+v", key, uc)
|
|
}
|
|
}
|
|
if len(cfg.Upstream) != 2 {
|
|
t.Errorf("upstreams = %v, want the Control D upstream plus one internal", cfg.Upstream)
|
|
}
|
|
}
|
|
|
|
// Mode is what the administrator selected, so it decides the routing. The list
|
|
// of resolvers is only read when the mode calls for it.
|
|
func TestApplyInternalDomainsModeIsAuthoritative(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
entry controld.SplitDNS
|
|
wantTargets []string
|
|
wantRule bool
|
|
wantUpstreams int
|
|
}{
|
|
{
|
|
// The administrator switched back to the OS resolver; addresses the
|
|
// previous selection left behind must not resurrect themselves.
|
|
name: "os mode ignores leftover resolvers",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeOS, Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{},
|
|
wantRule: true,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
// Explicit with nothing to be explicit about is not OS resolution.
|
|
name: "resolvers mode with an empty list is dropped",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers},
|
|
wantRule: false,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
name: "resolvers mode with only malformed addresses is dropped",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"nope"}},
|
|
wantRule: false,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
name: "resolvers mode routes explicitly",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{"upstream.internal_0"},
|
|
wantRule: true,
|
|
wantUpstreams: 2,
|
|
},
|
|
{
|
|
name: "mode is matched case and space insensitively",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: " OS ", Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{},
|
|
wantRule: true,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
// A wire value this build does not know fails closed to the
|
|
// resolvers the administrator chose, never to the Control D upstream.
|
|
name: "unknown mode with resolvers fails closed",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: "forward-someday", Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{"upstream.internal_0"},
|
|
wantRule: true,
|
|
wantUpstreams: 2,
|
|
},
|
|
{
|
|
// With no resolvers there is nothing to fail closed to.
|
|
name: "unknown mode without resolvers is dropped",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: "forward-someday"},
|
|
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")
|
|
}
|
|
}
|