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" 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.
663 lines
25 KiB
Go
663 lines
25 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/miekg/dns"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"github.com/Control-D-Inc/ctrld/internal/controld"
|
|
)
|
|
|
|
// captureInternalDomainsLogs uses a test-local JSON sink so counts and privacy
|
|
// assertions inspect structured fields at their actual levels, independent of
|
|
// TestMain's console encoder and any previous test replacing mainLog. Call it
|
|
// before constructing a prog: its logger and monitor retain the selected logger.
|
|
func captureInternalDomainsLogs(t *testing.T) *syncBuffer {
|
|
t.Helper()
|
|
buf := &syncBuffer{}
|
|
core := zapcore.NewCore(
|
|
zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()),
|
|
zapcore.AddSync(buf),
|
|
zap.DebugLevel,
|
|
)
|
|
old := mainLog.Load()
|
|
mainLog.Store(&ctrld.Logger{Logger: zap.New(core)})
|
|
t.Cleanup(func() { mainLog.Store(old) })
|
|
return buf
|
|
}
|
|
|
|
func internalDomainsTestConfig() *ctrld.Config {
|
|
return &ctrld.Config{
|
|
Listener: map[string]*ctrld.ListenerConfig{"0": {Policy: &ctrld.ListenerPolicyConfig{Name: "My Policy"}}},
|
|
Upstream: map[string]*ctrld.UpstreamConfig{"0": {Endpoint: "https://dns.controld.com/test", Type: ctrld.ResolverTypeDOH, BootstrapIP: "127.0.0.1"}},
|
|
Network: map[string]*ctrld.NetworkConfig{"0": {Name: "Network 0", Cidrs: []string{"0.0.0.0/0"}}},
|
|
}
|
|
}
|
|
|
|
// ruleTargets returns the targets of the rule for source, and whether it exists.
|
|
func ruleTargets(t *testing.T, cfg *ctrld.Config, source string) ([]string, bool) {
|
|
t.Helper()
|
|
for _, rule := range cfg.Listener["0"].Policy.Rules {
|
|
if targets, ok := rule[source]; ok {
|
|
return targets, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func TestNormalizeInternalDomain(t *testing.T) {
|
|
long63 := strings.Repeat("a", 63)
|
|
for _, tc := range []struct{ name, in, want string }{
|
|
// Canonicalization that cannot change which names match.
|
|
{"plain", "aws.example.com", "aws.example.com"},
|
|
{"case and root dot", " AWS.Example.COM. ", "aws.example.com"},
|
|
{"leading wildcard", "*.aws.example.com", "aws.example.com"},
|
|
{"surrounding control whitespace", "\r\nCORP.EXAMPLE.\r\n", "corp.example"},
|
|
{"single label", "intranet", "intranet"},
|
|
{"punycode", "xn--80ak6aa92e.com", "xn--80ak6aa92e.com"},
|
|
{"digits and hyphens", "dc-01.site2.example.com", "dc-01.site2.example.com"},
|
|
{"max label", long63 + ".example.com", long63 + ".example.com"},
|
|
|
|
// Rejected: an empty or absent name.
|
|
{"empty", "", ""},
|
|
{"whitespace only", " ", ""},
|
|
{"root only", ".", ""},
|
|
{"wildcard only", "*.", ""},
|
|
|
|
// Rejected: empty labels. A leading dot is not repaired, because the
|
|
// repaired value is a different suffix from the configured one.
|
|
{"leading dot", ".aws.example.com.", ""},
|
|
{"double dot", "aws..example.com", ""},
|
|
{"trailing double dot", "aws.example.com..", ""},
|
|
|
|
// Rejected: characters outside the ASCII hostname contract. A denylist
|
|
// would have to name every one of these; the positive rule does not.
|
|
{"space", "has space.com", ""},
|
|
{"embedded wildcard", "we*rd.com", ""},
|
|
{"slash", "a/b.com", ""},
|
|
{"newline", "aws\nevil.example.com", ""},
|
|
{"carriage return", "aws\revil.example.com", ""},
|
|
{"nul", "aws\x00evil.example.com", ""},
|
|
{"escape", "aws\x1bevil.example.com", ""},
|
|
{"delete", "aws\x7fevil.example.com", ""},
|
|
{"quote", "aws\"evil.example.com", ""},
|
|
{"colon", "aws:53.example.com", ""},
|
|
{"underscore", "_msdcs.example.com", ""},
|
|
{"non-ascii", "exämple.com", ""},
|
|
|
|
// Rejected: hyphen placement and length.
|
|
{"leading hyphen", "-aws.example.com", ""},
|
|
{"trailing hyphen", "aws-.example.com", ""},
|
|
{"label too long", strings.Repeat("a", 64) + ".example.com", ""},
|
|
{"name too long", strings.Repeat("a.", 127) + "example.com", ""},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := normalizeInternalDomain(tc.in); got != tc.want {
|
|
t.Errorf("normalizeInternalDomain(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A rejected domain is counted and produces no rule, rather than being repaired
|
|
// into a suffix nobody configured.
|
|
func TestApplyInternalDomainsRejectsMalformedDomains(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws\nevil.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}},
|
|
{Domain: "ok.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.54"}},
|
|
})
|
|
if summary.domains != 1 || summary.skipped != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
for _, rule := range cfg.Listener["0"].Policy.Rules {
|
|
for source := range rule {
|
|
if strings.ContainsAny(source, "\n\r\x00") {
|
|
t.Errorf("control character reached a policy rule: %q", source)
|
|
}
|
|
if source != "ok.example.com" && source != "*.ok.example.com" {
|
|
t.Errorf("unexpected rule source %q", source)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInternalDomainEndpoint(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
in string
|
|
want string
|
|
ok bool
|
|
}{
|
|
{"10.0.0.10", "10.0.0.10:53", true},
|
|
{" 10.0.0.11 ", "10.0.0.11:53", true},
|
|
{"2001:db8::1", "[2001:db8::1]:53", true},
|
|
{"10.0.0.10:5353", "10.0.0.10:5353", true},
|
|
{"[2001:db8::1]:5353", "[2001:db8::1]:5353", true},
|
|
{"dns.internal.example.com", "", false},
|
|
{"10.0.0.256", "", false},
|
|
{"", "", false},
|
|
{"10.0.0.10:0", "", false},
|
|
{"10.0.0.10:70000", "", false},
|
|
{"10.0.0.10:dns", "", false},
|
|
} {
|
|
got, ok := internalDomainEndpoint(tc.in)
|
|
if got != tc.want || ok != tc.ok {
|
|
t.Errorf("internalDomainEndpoint(%q) = %q, %v; want %q, %v", tc.in, got, ok, tc.want, tc.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyInternalDomainsOSResolverMode(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{{Domain: "corp.example.com"}})
|
|
|
|
if summary.domains != 1 || summary.osMode != 1 || summary.explicit != 0 || summary.resolvers != 0 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
for _, source := range []string{"corp.example.com", "*.corp.example.com"} {
|
|
targets, ok := ruleTargets(t, cfg, source)
|
|
if !ok {
|
|
t.Fatalf("no rule for %q", source)
|
|
}
|
|
if len(targets) != 0 {
|
|
t.Errorf("rule %q targets = %v, want empty (OS resolver)", source, targets)
|
|
}
|
|
}
|
|
if len(cfg.Upstream) != 1 {
|
|
t.Errorf("upstreams = %v, want only the Control D upstream", cfg.Upstream)
|
|
}
|
|
}
|
|
|
|
func TestApplyInternalDomainsExplicitResolverMode(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "2001:db8::11"}},
|
|
})
|
|
|
|
if summary.domains != 1 || summary.explicit != 1 || summary.osMode != 0 || summary.resolvers != 2 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
want := []string{"upstream.internal_0", "upstream.internal_1"}
|
|
for _, source := range []string{"aws.example.com", "*.aws.example.com"} {
|
|
targets, ok := ruleTargets(t, cfg, source)
|
|
if !ok {
|
|
t.Fatalf("no rule for %q", source)
|
|
}
|
|
if strings.Join(targets, ",") != strings.Join(want, ",") {
|
|
t.Errorf("rule %q targets = %v, want %v (administrator order)", source, targets, want)
|
|
}
|
|
}
|
|
if got := cfg.Upstream["internal_0"]; got == nil || got.Endpoint != "10.0.0.10:53" || got.Type != ctrld.ResolverTypeLegacy {
|
|
t.Errorf("internal_0 = %+v", got)
|
|
}
|
|
if got := cfg.Upstream["internal_1"]; got == nil || got.Endpoint != "[2001:db8::11]:53" {
|
|
t.Errorf("internal_1 = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyInternalDomainsMalformedResolvers(t *testing.T) {
|
|
t.Run("drops only the malformed address", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"not-an-ip", "10.0.0.10"}},
|
|
})
|
|
if summary.domains != 1 || summary.resolvers != 1 || summary.skipped != 0 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
targets, _ := ruleTargets(t, cfg, "aws.example.com")
|
|
if len(targets) != 1 || targets[0] != "upstream.internal_0" {
|
|
t.Errorf("targets = %v", targets)
|
|
}
|
|
})
|
|
|
|
t.Run("drops the entry when no address is usable", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"not-an-ip", ""}},
|
|
})
|
|
if summary.domains != 0 || summary.skipped != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
// Falling back to the OS resolver here would silently replace the
|
|
// administrator's explicit selection with a different one.
|
|
if _, ok := ruleTargets(t, cfg, "aws.example.com"); ok {
|
|
t.Error("entry with no usable resolver must not produce a rule")
|
|
}
|
|
if len(cfg.Upstream) != 1 {
|
|
t.Errorf("upstreams = %v, want no generated upstream", cfg.Upstream)
|
|
}
|
|
})
|
|
|
|
t.Run("drops an unusable domain", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{{Domain: " ", Resolvers: []string{"10.0.0.10"}}})
|
|
if summary.domains != 0 || summary.skipped != 1 || len(cfg.Upstream) != 1 {
|
|
t.Fatalf("summary = %+v, upstreams = %v", summary, cfg.Upstream)
|
|
}
|
|
})
|
|
|
|
t.Run("drops a duplicate domain", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
|
|
{Domain: "AWS.Example.com.", Resolvers: []string{"10.0.0.99"}},
|
|
})
|
|
if summary.domains != 1 || summary.skipped != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
targets, _ := ruleTargets(t, cfg, "aws.example.com")
|
|
if len(targets) != 1 || cfg.Upstream["internal_0"].Endpoint != "10.0.0.10:53" {
|
|
t.Errorf("first entry must win: targets = %v", targets)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Magic Folder excludes and endpoint custom configuration are written to the
|
|
// policy before Internal Domains are applied, and must keep their routing.
|
|
func TestApplyInternalDomainsPrecedence(t *testing.T) {
|
|
t.Run("an existing rule is never overwritten", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
cfg.Listener["0"].Policy.Rules = []ctrld.Rule{{"aws.example.com": []string{}}}
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
|
|
})
|
|
targets, _ := ruleTargets(t, cfg, "aws.example.com")
|
|
if len(targets) != 0 {
|
|
t.Errorf("exclude rule was overwritten: targets = %v", targets)
|
|
}
|
|
// The wildcard was still free, so the domain is only partly preempted.
|
|
if wildcard, ok := ruleTargets(t, cfg, "*.aws.example.com"); !ok || len(wildcard) != 1 {
|
|
t.Errorf("wildcard rule = %v, %v", wildcard, ok)
|
|
}
|
|
if summary.domains != 1 {
|
|
t.Errorf("summary = %+v", summary)
|
|
}
|
|
})
|
|
|
|
t.Run("a fully covered domain is reported as preempted", func(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
cfg.Listener["0"].Policy.Rules = []ctrld.Rule{
|
|
{"aws.example.com": []string{"upstream.0"}},
|
|
{"*.aws.example.com": []string{"upstream.0"}},
|
|
}
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
|
|
})
|
|
if summary.domains != 0 || summary.preempted != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
if len(cfg.Listener["0"].Policy.Rules) != 2 {
|
|
t.Errorf("rules = %v, want no addition", cfg.Listener["0"].Policy.Rules)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A configured suffix must route itself and its subdomains, and nothing else.
|
|
func TestApplyInternalDomainsSuffixMatching(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
|
|
{Domain: "corp.example.com"},
|
|
})
|
|
p := newInternalDomainsProg(t, cfg)
|
|
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
domain string
|
|
want []string
|
|
}{
|
|
{"aws.example.com", []string{"upstream.internal_0"}},
|
|
{"host.aws.example.com", []string{"upstream.internal_0"}},
|
|
{"a.b.c.aws.example.com", []string{"upstream.internal_0"}},
|
|
{"corp.example.com", []string{}},
|
|
{"host.corp.example.com", []string{}},
|
|
{"example.com", []string{"upstream.0"}},
|
|
{"notaws.example.com", []string{"upstream.0"}},
|
|
{"aws.example.com.evil.test", []string{"upstream.0"}},
|
|
} {
|
|
ufr := p.upstreamFor(context.Background(), "0", cfg.Listener["0"], addr, "", tc.domain)
|
|
if strings.Join(ufr.upstreams, ",") != strings.Join(tc.want, ",") {
|
|
t.Errorf("upstreamFor(%q) = %v, want %v", tc.domain, ufr.upstreams, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A refresh rebuilds the generated config, so a removal must leave no rule and
|
|
// no upstream behind.
|
|
func TestApplyInternalDomainsAuthoritativeReplacement(t *testing.T) {
|
|
first := internalDomainsTestConfig()
|
|
applyInternalDomains(first, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10"}},
|
|
{Domain: "old.example.com", Resolvers: []string{"10.0.0.20"}},
|
|
})
|
|
if len(first.Upstream) != 3 {
|
|
t.Fatalf("upstreams = %v", first.Upstream)
|
|
}
|
|
|
|
// The refresh path regenerates cfg from the API response rather than
|
|
// mutating the previous one.
|
|
second := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(second, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.99"}},
|
|
})
|
|
if summary.domains != 1 || summary.resolvers != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
if _, ok := ruleTargets(t, second, "old.example.com"); ok {
|
|
t.Error("removed domain left a stale rule")
|
|
}
|
|
if len(second.Upstream) != 2 {
|
|
t.Errorf("upstreams = %v, want the Control D upstream plus one internal", second.Upstream)
|
|
}
|
|
if second.Upstream["internal_0"].Endpoint != "10.0.0.99:53" {
|
|
t.Errorf("resolver change not applied: %+v", second.Upstream["internal_0"])
|
|
}
|
|
}
|
|
|
|
func TestApplyInternalDomainsEmpty(t *testing.T) {
|
|
for _, entries := range [][]controld.SplitDNS{nil, {}} {
|
|
cfg := internalDomainsTestConfig()
|
|
summary := applyInternalDomains(cfg, entries)
|
|
if !summary.empty() {
|
|
t.Errorf("summary = %+v, want empty", summary)
|
|
}
|
|
if len(cfg.Listener["0"].Policy.Rules) != 0 || len(cfg.Upstream) != 1 {
|
|
t.Errorf("absent split_dns changed the config: rules=%v upstreams=%v",
|
|
cfg.Listener["0"].Policy.Rules, cfg.Upstream)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInternalDomainsEqual(t *testing.T) {
|
|
base := []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
|
|
{Domain: "corp.example.com"},
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
next []controld.SplitDNS
|
|
want bool
|
|
}{
|
|
{"unchanged", base, true},
|
|
{"reordered domains", []controld.SplitDNS{
|
|
{Domain: "corp.example.com"},
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
|
|
}, true},
|
|
{"equivalent spelling", []controld.SplitDNS{
|
|
{Domain: "AWS.Example.com.", Resolvers: []string{" 10.0.0.10 ", "10.0.0.11:53"}},
|
|
{Domain: "*.corp.example.com"},
|
|
}, true},
|
|
{"resolver order changed", []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.11", "10.0.0.10"}},
|
|
{Domain: "corp.example.com"},
|
|
}, false},
|
|
{"resolver changed", []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.99"}},
|
|
{Domain: "corp.example.com"},
|
|
}, false},
|
|
{"domain removed", base[:1], false},
|
|
{"domain added", append(append([]controld.SplitDNS{}, base...), controld.SplitDNS{Domain: "new.example.com"}), false},
|
|
{"mode changed to explicit", []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
|
|
{Domain: "corp.example.com", Resolvers: []string{"10.0.0.30"}},
|
|
}, false},
|
|
{"emptied", nil, false},
|
|
} {
|
|
if got := internalDomainsEqual(base, tc.next); got != tc.want {
|
|
t.Errorf("%s: internalDomainsEqual = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
if !internalDomainsEqual(nil, []controld.SplitDNS{}) {
|
|
t.Error("nil and empty lists must compare equal")
|
|
}
|
|
}
|
|
|
|
func TestInternalDomainExplicitUpstreams(t *testing.T) {
|
|
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) {
|
|
buf := captureInternalDomainsLogs(t)
|
|
logger := mainLog.Load()
|
|
logInternalDomainsSummary(logger, internalDomainsSummary{
|
|
domains: 2, osMode: 1, explicit: 1, resolvers: 3, skipped: 1,
|
|
})
|
|
out := buf.String()
|
|
for _, want := range []string{`"domains":2`, `"os_resolver":1`, `"explicit_resolver":1`, `"resolvers":3`, `"skipped":1`} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("log %q missing %q", out, want)
|
|
}
|
|
}
|
|
if !strings.Contains(out, `"level":"warn"`) {
|
|
t.Errorf("a summary with skipped entries must warn: %q", out)
|
|
}
|
|
|
|
mark := len(buf.String())
|
|
logInternalDomainsSummary(logger, internalDomainsSummary{domains: 1, osMode: 1})
|
|
if !strings.Contains(buf.String()[mark:], `"level":"info"`) {
|
|
t.Errorf("a clean summary must be info: %q", buf.String())
|
|
}
|
|
}
|
|
|
|
// An explicit-resolver Internal Domain must not be handed to VPN DNS split
|
|
// routing: the administrator named those resolvers, and VPN suffixes are
|
|
// auto-detected. 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) {
|
|
buf := captureInternalDomainsLogs(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 := newInternalDomainsProg(t, cfg)
|
|
p.vpnDNS = &vpnDNSManager{routes: map[string][]string{"example.com": {"127.0.0.1:1"}}}
|
|
|
|
prevIntercept := dnsIntercept
|
|
dnsIntercept = true
|
|
t.Cleanup(func() { dnsIntercept = prevIntercept })
|
|
|
|
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.WithValue(ctrld.LoggerCtx(context.Background(), p.logger.Load()), ctrld.ReqIdCtxKey{}, requestID())
|
|
ufr := p.upstreamFor(ctx, "0", cfg.Listener["0"], addr, "", "host.aws.example.com")
|
|
if !p.internalDomainExplicitUpstreams(ufr.upstreams) {
|
|
t.Fatalf("upstreams = %v, want only internal resolvers", ufr.upstreams)
|
|
}
|
|
|
|
// Only this call's tail describes what proxy() did here.
|
|
before := len(buf.String())
|
|
// A trailing dot: without it the name is not a valid wire question, and the
|
|
// failure would prove nothing about whether a resolver was contacted.
|
|
res := p.proxy(ctx, &proxyRequest{msg: newDnsMsgWithHostname("host.aws.example.com.", dns.TypeA), ufr: ufr})
|
|
out := buf.String()[before:]
|
|
|
|
if strings.Contains(out, "VPN DNS route matched") {
|
|
t.Errorf("VPN split routing overrode an explicit Internal Domain: %s", out)
|
|
}
|
|
if strings.Contains(out, "Attempting query to OS resolver as a retry catch all") {
|
|
t.Errorf("unreachable internal resolvers leaked to the OS resolver: %s", out)
|
|
}
|
|
if res == nil || res.answer == nil || res.answer.Rcode != dns.RcodeServerFailure {
|
|
t.Errorf("answer = %v, want SERVFAIL", res)
|
|
}
|
|
}
|
|
|
|
// A domain that produces no rule must produce no upstream either, or the
|
|
// generated config would carry an internal_* upstream nothing refers to.
|
|
func TestApplyInternalDomainsLeaveNoOrphanUpstreams(t *testing.T) {
|
|
cfg := internalDomainsTestConfig()
|
|
cfg.Listener["0"].Policy.Rules = []ctrld.Rule{
|
|
{"aws.example.com": []string{"upstream.0"}},
|
|
{"*.aws.example.com": []string{"upstream.0"}},
|
|
}
|
|
summary := applyInternalDomains(cfg, []controld.SplitDNS{
|
|
{Domain: "aws.example.com", Resolvers: []string{"10.0.0.10", "10.0.0.11"}},
|
|
{Domain: "ok.example.com", Resolvers: []string{"10.0.0.20"}},
|
|
})
|
|
if summary.preempted != 1 || summary.domains != 1 || summary.resolvers != 1 {
|
|
t.Fatalf("summary = %+v", summary)
|
|
}
|
|
for key, uc := range cfg.Upstream {
|
|
if !strings.HasPrefix(key, internalDomainUpstreamPrefix) {
|
|
continue
|
|
}
|
|
if uc.Endpoint != "10.0.0.20:53" {
|
|
t.Errorf("orphan upstream %s = %+v", key, uc)
|
|
}
|
|
}
|
|
if len(cfg.Upstream) != 2 {
|
|
t.Errorf("upstreams = %v, want the Control D upstream plus one internal", cfg.Upstream)
|
|
}
|
|
}
|
|
|
|
// Mode is what the administrator selected, so it decides the routing. The list
|
|
// of resolvers is only read when the mode calls for it.
|
|
func TestApplyInternalDomainsModeIsAuthoritative(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
entry controld.SplitDNS
|
|
wantTargets []string
|
|
wantRule bool
|
|
wantUpstreams int
|
|
}{
|
|
{
|
|
// The administrator switched back to the OS resolver; addresses the
|
|
// previous selection left behind must not resurrect themselves.
|
|
name: "os mode ignores leftover resolvers",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeOS, Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{},
|
|
wantRule: true,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
// Explicit with nothing to be explicit about is not OS resolution.
|
|
name: "resolvers mode with an empty list is dropped",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers},
|
|
wantRule: false,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
name: "resolvers mode with only malformed addresses is dropped",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"nope"}},
|
|
wantRule: false,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
name: "resolvers mode routes explicitly",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: controld.SplitDNSModeResolvers, Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{"upstream.internal_0"},
|
|
wantRule: true,
|
|
wantUpstreams: 2,
|
|
},
|
|
{
|
|
name: "mode is matched case and space insensitively",
|
|
entry: controld.SplitDNS{Domain: "a.example.com", Mode: " OS ", Resolvers: []string{"10.0.0.53"}},
|
|
wantTargets: []string{},
|
|
wantRule: true,
|
|
wantUpstreams: 1,
|
|
},
|
|
{
|
|
// A 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")
|
|
}
|
|
}
|