Files
ctrld/cmd/cli/internal_domains_test.go
T
Cuong Manh Le 25b79d5e73 cli: route organization Internal Domains from managed config
Port the final merged change from !1009 to master, retaining master's
context-aware logger/resolver APIs, proxy decomposition and firewall
refresh handling. Adapt source regression tests and cover master's
refresh handler and transport-level private-address diagnostics.
2026-09-18 13:37:27 +07:00

649 lines
24 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) {
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) {
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. 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", 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 !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 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")
}
}