mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
Consume resolver.split_dns and generate a policy rule per domain, plus its wildcard, so a suffix and its subdomains route to the OS resolver when no resolvers are given, or to the configured resolvers when they are. Explicit resolvers are exclusive: when all of them fail the query returns SERVFAIL rather than taking the OS catch-all or starting recovery, and it skips VPN split routing, which is auto-detected. Excludes and custom config keep precedence; a refresh regenerates the config, so removals leave no stale rule or upstream.
633 lines
23 KiB
Go
633 lines
23 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) {
|
|
for _, tc := range []struct {
|
|
in []string
|
|
want bool
|
|
}{
|
|
{[]string{"upstream.internal_0"}, true},
|
|
{[]string{"upstream.internal_0", "upstream.internal_1"}, true},
|
|
{[]string{"upstream.internal_0", "upstream.0"}, false},
|
|
{[]string{"upstream.0"}, false},
|
|
{[]string{upstreamOS}, false},
|
|
{nil, false},
|
|
{[]string{}, false},
|
|
} {
|
|
if got := internalDomainExplicitUpstreams(tc.in); got != tc.want {
|
|
t.Errorf("internalDomainExplicitUpstreams(%v) = %v, want %v", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Info and warning logs must not carry organization domain names or resolver
|
|
// addresses; those stay at debug level.
|
|
func TestInternalDomainsSummaryLogsCountsOnly(t *testing.T) {
|
|
var buf strings.Builder
|
|
logger := zerolog.New(&buf)
|
|
logInternalDomainsSummary(logger, internalDomainsSummary{
|
|
domains: 2, osMode: 1, explicit: 1, resolvers: 3, skipped: 1,
|
|
})
|
|
out := buf.String()
|
|
for _, want := range []string{`"domains":2`, `"os_resolver":1`, `"explicit_resolver":1`, `"resolvers":3`, `"skipped":1`} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("log %q missing %q", out, want)
|
|
}
|
|
}
|
|
if !strings.Contains(out, `"level":"warn"`) {
|
|
t.Errorf("a summary with skipped entries must warn: %q", out)
|
|
}
|
|
|
|
buf.Reset()
|
|
logInternalDomainsSummary(logger, internalDomainsSummary{domains: 1, osMode: 1})
|
|
if !strings.Contains(buf.String(), `"level":"info"`) {
|
|
t.Errorf("a clean summary must be info: %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// An explicit-resolver Internal Domain must not be handed to VPN DNS split
|
|
// routing: the administrator named those resolvers, and VPN suffixes are
|
|
// auto-detected. When the named resolvers are unreachable the query fails
|
|
// instead of reaching the OS resolver.
|
|
func TestInternalDomainsBeatVPNSplitRoutingAndDoNotLeak(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(true)
|
|
applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"127.0.0.1:1"}},
|
|
})
|
|
|
|
p := &prog{cfg: cfg}
|
|
p.um = newUpstreamMonitor(p.cfg)
|
|
p.lanLoopGuard = newLoopGuard()
|
|
p.ptrLoopGuard = newLoopGuard()
|
|
p.vpnDNS = &vpnDNSManager{routes: map[string][]string{"example.com": {"127.0.0.1:1"}}}
|
|
|
|
prevIntercept := dnsIntercept
|
|
dnsIntercept = true
|
|
t.Cleanup(func() { dnsIntercept = prevIntercept })
|
|
|
|
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
|
|
ufr := p.upstreamFor(ctx, "0", cfg.Listener["0"], addr, "", "host.aws.example.com")
|
|
if !internalDomainExplicitUpstreams(ufr.upstreams) {
|
|
t.Fatalf("upstreams = %v, want only internal resolvers", ufr.upstreams)
|
|
}
|
|
|
|
// logOutput accumulates for the whole test binary; only this call's tail
|
|
// describes what proxy() did here.
|
|
before := len(logOutput.String())
|
|
// A trailing dot: without it the name is not a valid wire question, and the
|
|
// failure would prove nothing about whether a resolver was contacted.
|
|
res := p.proxy(ctx, &proxyRequest{msg: newDnsMsgWithHostname("host.aws.example.com.", dns.TypeA), ufr: ufr})
|
|
out := logOutput.String()[before:]
|
|
|
|
if strings.Contains(out, "VPN DNS route matched") {
|
|
t.Errorf("VPN split routing overrode an explicit Internal Domain: %s", out)
|
|
}
|
|
if strings.Contains(out, "attempting query to OS resolver as a retry catch all") {
|
|
t.Errorf("unreachable internal resolvers leaked to the OS resolver: %s", out)
|
|
}
|
|
if res == nil || res.answer == nil || res.answer.Rcode != dns.RcodeServerFailure {
|
|
t.Errorf("answer = %v, want SERVFAIL", res)
|
|
}
|
|
}
|
|
|
|
// A domain that produces no rule must produce no upstream either, or the
|
|
// generated config would carry an internal_* upstream nothing refers to.
|
|
func TestApplyInternalDomainsLeaveNoOrphanUpstreams(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
cfg.Listener["0"].Policy.Rules = []ctrld.Rule{
|
|
{"aws.example.com": []string{"upstream.0"}},
|
|
{"*.aws.example.com": []string{"upstream.0"}},
|
|
}
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
|
|
{Domain: "ok.example.com", Resolvers: []string{"10.0.0.20"}},
|
|
})
|
|
if summary.preempted != 1 || summary.domains != 1 || summary.resolvers != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
for key, uc := range cfg.Upstream {
|
|
if !strings.HasPrefix(key, internalDomainUpstreamPrefix) {
|
|
continue
|
|
}
|
|
if uc.Endpoint != "10.0.0.20:53" {
|
|
t.Errorf("orphan upstream %s = %+v", key, uc)
|
|
}
|
|
}
|
|
if len(cfg.Upstream) != 2 {
|
|
t.Errorf("upstreams = %v, want the Control D upstream plus one internal", cfg.Upstream)
|
|
}
|
|
}
|
|
|
|
// Mode is what the administrator selected, so it decides the routing. The list
|
|
// of resolvers is only read when the mode calls for it.
|
|
func TestApplyInternalDomainsModeIsAuthoritative(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
entry controld.SplitDNS
|
|
wantTargets []string
|
|
wantRule bool
|
|
wantUpstreams int
|
|
}{
|
|
{
|
|
// The administrator switched back to the OS resolver; addresses the
|
|
// previous selection left behind must not resurrect themselves.
|
|
name: "os mode ignores leftover resolvers",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeOS, Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{},
|
|
wantRule: true,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
// Explicit with nothing to be explicit about is not OS resolution.
|
|
name: "resolvers mode with an empty list is dropped",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers},
|
|
wantRule: false,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
name: "resolvers mode with only malformed addresses is dropped",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"nope"}},
|
|
wantRule: false,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
name: "resolvers mode routes explicitly",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{"upstream.internal_0"},
|
|
wantRule: true,
|
|
wantUpstreams: 2,
|
|
},
|
|
{
|
|
name: "mode is matched case and space insensitively",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: " OS ", Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{},
|
|
wantRule: true,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
// A mode ctrld does not know is not guessed at.
|
|
name: "unknown mode is dropped",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: "forward-someday", Resolvers: []string{"10.0.0.53"}},
|
|
wantRule: false,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
// A deployment that predates the field: the list is the only signal.
|
|
name: "absent mode infers explicit",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{"upstream.internal_0"},
|
|
wantRule: true,
|
|
wantUpstreams: 2,
|
|
},
|
|
{
|
|
name: "absent mode infers os",
|
|
entry: controld.SplitDNS{Domain: "a.example.com"},
|
|
wantTargets: []string{},
|
|
wantRule: true,
|
|
wantUpstreams: 1,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
applyInternalDomains(cfg, []controld.SplitDNS{tc.entry})
|
|
targets, ok := ruleTargets(t, cfg, "a.example.com")
|
|
if ok != tc.wantRule {
|
|
t.Fatalf("rule present = %v, want %v", ok, tc.wantRule)
|
|
}
|
|
if ok && strings.Join(targets, ",") != strings.Join(tc.wantTargets, ",") {
|
|
t.Errorf("targets = %v, want %v", targets, tc.wantTargets)
|
|
}
|
|
if len(cfg.Upstream) != tc.wantUpstreams {
|
|
t.Errorf("upstreams = %v, want %d", cfg.Upstream, tc.wantUpstreams)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A mode change is a routing change even when the resolver list is untouched,
|
|
// so it has to reach the endpoint on the next refresh.
|
|
func TestInternalDomainsEqualTracksMode(t *testing.T) {
|
|
explicit := []controld.SplitDNS{{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}}}
|
|
os := []controld.SplitDNS{{Domain: "a.example.com", Mode: controld.SplitDNSModeOS, Resolvers: []string{"10.0.0.53"}}}
|
|
if internalDomainsEqual(explicit, os) {
|
|
t.Error("switching to the OS resolver must be detected as a change")
|
|
}
|
|
if internalDomainsEqual(os, explicit) {
|
|
t.Error("switching to explicit resolvers must be detected as a change")
|
|
}
|
|
// An unusable entry routes nothing, so it must not look like a change.
|
|
unusable := []controld.SplitDNS{
|
|
{Domain: "a.example.com", Mode: controld.SplitDNSModeOS, Resolvers: []string{"10.0.0.53"}},
|
|
{Domain: "b.example.com", Mode: "forward-someday"},
|
|
}
|
|
if !internalDomainsEqual(os, unusable) {
|
|
t.Error("an entry ctrld cannot use must not read as a configuration change")
|
|
}
|
|
}
|