mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
927 lines
34 KiB
Go
927 lines
34 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"github.com/Control-D-Inc/ctrld/internal/dnscache"
|
|
"github.com/Control-D-Inc/ctrld/testhelper"
|
|
)
|
|
|
|
func Test_wildcardMatches(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
wildcard string
|
|
domain string
|
|
match bool
|
|
}{
|
|
{"domain - prefix parent should not match", "*.example.com", "example.com", false},
|
|
{"domain - prefix", "*.example.com", "anything.example.com", true},
|
|
{"domain - prefix not match other s", "*.example.com", "other.org", false},
|
|
{"domain - prefix not match s in name", "*.example.com", "eexample.com", false},
|
|
{"domain - suffix", "suffix.*", "suffix.example.com", true},
|
|
{"domain - suffix not match other", "suffix.*", "suffix1.example.com", false},
|
|
{"domain - both", "suffix.*.example.com", "suffix.anything.example.com", true},
|
|
{"domain - both not match", "suffix.*.example.com", "suffix1.suffix.example.com", false},
|
|
{"domain - case-insensitive", "*.EXAMPLE.com", "anything.example.com", true},
|
|
{"mac - prefix", "*:98:05:b4:2b", "d4:67:98:05:b4:2b", true},
|
|
{"mac - prefix not match other s", "*:98:05:b4:2b", "0d:ba:54:09:94:2c", false},
|
|
{"mac - prefix not match s in name", "*:98:05:b4:2b", "e4:67:97:05:b4:2b", false},
|
|
{"mac - suffix", "d4:67:98:*", "d4:67:98:05:b4:2b", true},
|
|
{"mac - suffix not match other", "d4:67:98:*", "d4:67:97:15:b4:2b", false},
|
|
{"mac - both", "d4:67:98:*:b4:2b", "d4:67:98:05:b4:2b", true},
|
|
{"mac - both not match", "d4:67:98:*:b4:2b", "d4:67:97:05:c4:2b", false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := wildcardMatches(tc.wildcard, tc.domain); got != tc.match {
|
|
t.Errorf("unexpected result, wildcard: %s, domain: %s, want: %v, got: %v", tc.wildcard, tc.domain, tc.match, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_canonicalName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
domain string
|
|
canonical string
|
|
}{
|
|
{"fqdn to canonical", "example.com.", "example.com"},
|
|
{"already canonical", "example.com", "example.com"},
|
|
{"case insensitive", "Example.Com.", "example.com"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := canonicalName(tc.domain); got != tc.canonical {
|
|
t.Errorf("unexpected result, want: %s, got: %s", tc.canonical, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_prog_upstreamFor(t *testing.T) {
|
|
cfg := testhelper.SampleConfig(t)
|
|
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
|
|
p := &prog{cfg: cfg}
|
|
p.um = newUpstreamMonitor(p.cfg)
|
|
p.lanLoopGuard = newLoopGuard()
|
|
p.ptrLoopGuard = newLoopGuard()
|
|
for _, nc := range p.cfg.Network {
|
|
for _, cidr := range nc.Cidrs {
|
|
_, ipNet, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nc.IPNets = append(nc.IPNets, ipNet)
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
ip string
|
|
mac string
|
|
defaultUpstreamNum string
|
|
lc *ctrld.ListenerConfig
|
|
domain string
|
|
upstreams []string
|
|
matched bool
|
|
testLogMsg string
|
|
}{
|
|
{"Policy map matches", "192.168.0.1:0", "", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.1", "upstream.0"}, true, ""},
|
|
{"Policy split matches", "192.168.0.1:0", "", "0", p.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, ""},
|
|
{"Policy map for other network matches", "192.168.1.2:0", "", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.0"}, true, ""},
|
|
{"No policy map for listener", "192.168.1.2:0", "", "1", p.cfg.Listener["1"], "abc.ru", []string{"upstream.1"}, false, ""},
|
|
{"unenforced loging", "192.168.1.2:0", "", "0", p.cfg.Listener["0"], "abc.ru", []string{"upstream.1"}, true, "My Policy, network.1 (unenforced), *.ru -> [upstream.1]"},
|
|
{"Policy Macs matches upper", "192.168.0.1:0", "14:45:A0:67:83:0A", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:45:a0:67:83:0a"},
|
|
{"Policy Macs matches lower", "192.168.0.1:0", "14:54:4a:8e:08:2d", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:54:4a:8e:08:2d"},
|
|
{"Policy Macs matches case-insensitive", "192.168.0.1:0", "14:54:4A:8E:08:2D", "0", p.cfg.Listener["0"], "abc.xyz", []string{"upstream.2"}, true, "14:54:4a:8e:08:2d"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
for _, network := range []string{"udp", "tcp"} {
|
|
var (
|
|
addr net.Addr
|
|
err error
|
|
)
|
|
switch network {
|
|
case "udp":
|
|
addr, err = net.ResolveUDPAddr(network, tc.ip)
|
|
case "tcp":
|
|
addr, err = net.ResolveTCPAddr(network, tc.ip)
|
|
}
|
|
require.NoError(t, err)
|
|
require.NotNil(t, addr)
|
|
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
|
|
ufr := p.upstreamFor(ctx, tc.defaultUpstreamNum, tc.lc, addr, tc.mac, tc.domain)
|
|
p.proxy(ctx, &proxyRequest{
|
|
msg: newDnsMsgWithHostname("foo", dns.TypeA),
|
|
ufr: ufr,
|
|
})
|
|
assert.Equal(t, tc.matched, ufr.matched)
|
|
assert.Equal(t, tc.upstreams, ufr.upstreams)
|
|
if tc.testLogMsg != "" {
|
|
assert.Contains(t, logOutput.String(), tc.testLogMsg)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// withZPADNSEchoBypass sets the three gates that guard the built-in Zscaler
|
|
// DNS echo bypass: the macOS-only platform gate, Intercept Mode, and the
|
|
// hard-mode opt-out.
|
|
func withZPADNSEchoBypass(t *testing.T, onDarwin, intercept, hard bool) {
|
|
t.Helper()
|
|
oldBypass, oldIntercept, oldHard := zpaDNSEchoBypassEnabled, dnsIntercept, hardIntercept
|
|
zpaDNSEchoBypassEnabled = onDarwin
|
|
dnsIntercept = intercept
|
|
hardIntercept = hard
|
|
t.Cleanup(func() {
|
|
zpaDNSEchoBypassEnabled = oldBypass
|
|
dnsIntercept = oldIntercept
|
|
hardIntercept = oldHard
|
|
})
|
|
}
|
|
|
|
// listenerWithRules builds a listener whose policy carries only domain rules.
|
|
func listenerWithRules(rules ...ctrld.Rule) *ctrld.ListenerConfig {
|
|
return &ctrld.ListenerConfig{
|
|
IP: "127.0.0.1",
|
|
Port: 53,
|
|
Policy: &ctrld.ListenerPolicyConfig{
|
|
Name: "Test Policy",
|
|
Rules: rules,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Test_prog_upstreamFor_ZPADNSEchoBypass pins the routing decision for issue
|
|
// 601: under macOS Intercept Mode the exact Zscaler health-check name — and
|
|
// nothing else — is routed like a Control D bypass rule, i.e. with an empty
|
|
// upstream list that proxy() then resolves through upstream.os.
|
|
func Test_prog_upstreamFor_ZPADNSEchoBypass(t *testing.T) {
|
|
cfg := testhelper.SampleConfig(t)
|
|
p := &prog{cfg: cfg}
|
|
for _, nc := range p.cfg.Network {
|
|
for _, cidr := range nc.Cidrs {
|
|
_, ipNet, err := net.ParseCIDR(cidr)
|
|
require.NoError(t, err)
|
|
nc.IPNets = append(nc.IPNets, ipNet)
|
|
}
|
|
}
|
|
|
|
// A listener whose policy routes the echo name to a specific non-OS
|
|
// upstream. That is a deliberate route for this exact name, so it wins.
|
|
lcWithEchoRule := listenerWithRules(ctrld.Rule{zpaDNSEchoDomain: []string{"upstream.2"}})
|
|
lcWithZscalerWildcardRule := listenerWithRules(ctrld.Rule{"*.zscaler.com": []string{"upstream.2"}})
|
|
|
|
// Empty targets are how a Control D profile's bypass list reaches ctrld
|
|
// (cfg.Listener rules built from resolverConfig.Exclude). This is the
|
|
// documented pre-fix workaround, so it must keep routing to the OS path AND
|
|
// stay classified as the health check, or upgrading a machine that still
|
|
// carries the workaround would lose the no-cache behavior.
|
|
lcWithEchoBypassRule := listenerWithRules(ctrld.Rule{zpaDNSEchoDomain: []string{}})
|
|
lcWithZscalerWildcardBypassRule := listenerWithRules(ctrld.Rule{"*.zscaler.com": []string{}})
|
|
|
|
// network.0 targets, i.e. what an un-bypassed query from 192.168.0.1 gets.
|
|
notBypassed := []string{"upstream.1", "upstream.0"}
|
|
const (
|
|
inNetwork0 = "192.168.0.1:0" // matches network.0, so listener.0's policy matches
|
|
outOfPolicy = "10.0.0.1:0" // matches no configured network
|
|
)
|
|
|
|
tests := []struct {
|
|
name string
|
|
onDarwin bool
|
|
intercept bool
|
|
hard bool
|
|
lc *ctrld.ListenerConfig
|
|
defaultNum string
|
|
srcAddr string
|
|
domain string
|
|
wantUpstreams []string
|
|
wantMatched bool
|
|
wantZPAEcho bool
|
|
}{
|
|
{"exact echo name routes to the OS resolver path", true, true, false, p.cfg.Listener["0"], "0", inNetwork0, zpaDNSEchoDomain, nil, true, true},
|
|
{"echo name is matched case-insensitively and with a trailing dot", true, true, false, p.cfg.Listener["0"], "0", inNetwork0, "DNSEchoTest.ZScaler.COM.", nil, true, true},
|
|
{"echo name is bypassed on a listener with no policy", true, true, false, p.cfg.Listener["1"], "1", inNetwork0, zpaDNSEchoDomain, nil, false, true},
|
|
|
|
// The bypass must not double as source authorization. serveDNS refuses a
|
|
// query on a Restricted listener when matched is false, so an
|
|
// unauthorized source must stay unmatched even for this name — while
|
|
// still being routed to the OS resolver path if the listener does serve
|
|
// it. See TestZPADNSEchoBypassDoesNotAuthorizeRestrictedListener.
|
|
{"unauthorized source is still unmatched", true, true, false, p.cfg.Listener["0"], "0", outOfPolicy, zpaDNSEchoDomain, nil, false, true},
|
|
|
|
// An explicit domain rule routing the name to a specific upstream
|
|
// outranks the built-in route; network/MAC policy targets, which say
|
|
// nothing about this name, do not.
|
|
{"explicit domain rule for the name wins", true, true, false, lcWithEchoRule, "0", inNetwork0, zpaDNSEchoDomain, []string{"upstream.2"}, true, false},
|
|
{"explicit wildcard rule covering the name wins", true, true, false, lcWithZscalerWildcardRule, "0", inNetwork0, zpaDNSEchoDomain, []string{"upstream.2"}, true, false},
|
|
|
|
// An explicit rule that itself selects the OS path (empty targets, i.e.
|
|
// the Control D bypass-list shape) keeps its own labels but is still the
|
|
// health check, so proxy() keeps it out of the cache.
|
|
{"existing exact bypass rule stays classified as the health check", true, true, false, lcWithEchoBypassRule, "0", inNetwork0, zpaDNSEchoDomain, nil, true, true},
|
|
{"existing wildcard bypass rule stays classified as the health check", true, true, false, lcWithZscalerWildcardBypassRule, "0", inNetwork0, zpaDNSEchoDomain, nil, true, true},
|
|
|
|
// Gating: the bypass exists only where the problem does.
|
|
{"not bypassed off macOS", false, true, false, p.cfg.Listener["0"], "0", inNetwork0, zpaDNSEchoDomain, notBypassed, true, false},
|
|
{"not bypassed outside Intercept Mode", true, false, false, p.cfg.Listener["0"], "0", inNetwork0, zpaDNSEchoDomain, notBypassed, true, false},
|
|
{"not bypassed in hard intercept mode", true, true, true, p.cfg.Listener["0"], "0", inNetwork0, zpaDNSEchoDomain, notBypassed, true, false},
|
|
|
|
// Scope: only the one exact name, so no neighbouring name loses filtering.
|
|
{"subdomain of the echo name is not bypassed", true, true, false, p.cfg.Listener["0"], "0", inNetwork0, "foo." + zpaDNSEchoDomain, notBypassed, true, false},
|
|
{"echo name as a prefix of another domain is not bypassed", true, true, false, p.cfg.Listener["0"], "0", inNetwork0, zpaDNSEchoDomain + ".evil.example", notBypassed, true, false},
|
|
{"name that merely ends with the echo name is not bypassed", true, true, false, p.cfg.Listener["0"], "0", inNetwork0, "evil" + zpaDNSEchoDomain, notBypassed, true, false},
|
|
{"parent Zscaler domain is not bypassed", true, true, false, p.cfg.Listener["0"], "0", inNetwork0, "zscaler.com", notBypassed, true, false},
|
|
{"ordinary domain is not bypassed", true, true, false, p.cfg.Listener["0"], "0", inNetwork0, "example.com", notBypassed, true, false},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
withZPADNSEchoBypass(t, tc.onDarwin, tc.intercept, tc.hard)
|
|
addr, err := net.ResolveUDPAddr("udp", tc.srcAddr)
|
|
require.NoError(t, err)
|
|
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
|
|
|
|
ufr := p.upstreamFor(ctx, tc.defaultNum, tc.lc, addr, "", tc.domain)
|
|
|
|
assert.Equal(t, tc.wantUpstreams, ufr.upstreams)
|
|
assert.Equal(t, tc.wantMatched, ufr.matched, "matched is the source-authorization bit")
|
|
assert.Equal(t, tc.wantZPAEcho, ufr.zpaDNSEcho)
|
|
if tc.wantZPAEcho {
|
|
// An empty upstream list is the whole mechanism: it is what
|
|
// proxy() turns into upstream.os. Guard the translation step
|
|
// that makes that substitution fire.
|
|
assert.Empty(t, p.upstreamConfigsFromUpstreamNumbers(ufr.upstreams))
|
|
if tc.lc.Policy == nil || len(tc.lc.Policy.Rules) == 0 {
|
|
// Only relabelled when the built-in route is the one that
|
|
// made the decision; an operator's own matching rule keeps
|
|
// its own labels.
|
|
assert.Equal(t, zpaDNSEchoPolicyName, ufr.matchedPolicy)
|
|
assert.Equal(t, zpaDNSEchoDomain, ufr.matchedRule)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestZPADNSEchoBypassDoesNotAuthorizeRestrictedListener pins the serveDNS
|
|
// consequence of keeping `matched` out of the bypass: a Restricted listener
|
|
// answers only sources that match its policy, and the built-in echo route must
|
|
// not become a hole in that for every macOS DNS-intercept instance.
|
|
func TestZPADNSEchoBypassDoesNotAuthorizeRestrictedListener(t *testing.T) {
|
|
withZPADNSEchoBypass(t, true, true, false)
|
|
|
|
cfg := testhelper.SampleConfig(t)
|
|
p := &prog{cfg: cfg}
|
|
for _, nc := range p.cfg.Network {
|
|
for _, cidr := range nc.Cidrs {
|
|
_, ipNet, err := net.ParseCIDR(cidr)
|
|
require.NoError(t, err)
|
|
nc.IPNets = append(nc.IPNets, ipNet)
|
|
}
|
|
}
|
|
lc := p.cfg.Listener["0"]
|
|
lc.Restricted = true
|
|
|
|
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
|
|
for _, tc := range []struct {
|
|
name string
|
|
srcAddr string
|
|
wantRefused bool
|
|
}{
|
|
{"source outside every network policy is refused", "10.0.0.1:0", true},
|
|
{"source inside network.0 is served", "192.168.0.1:0", false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
addr, err := net.ResolveUDPAddr("udp", tc.srcAddr)
|
|
require.NoError(t, err)
|
|
ufr := p.upstreamFor(ctx, "0", lc, addr, "", zpaDNSEchoDomain)
|
|
|
|
// The route itself is unconditional; only the authorization differs.
|
|
require.True(t, ufr.zpaDNSEcho)
|
|
// This is the exact condition serveDNS uses to answer REFUSED.
|
|
assert.Equal(t, tc.wantRefused, !ufr.matched && lc.Restricted)
|
|
})
|
|
}
|
|
}
|
|
|
|
// osResolverStub stands in for the OS resolver during a proxy() test. It
|
|
// answers NOERROR for anything, and records what it was actually asked, so a
|
|
// test can tell an answer that came off the wire from one served locally.
|
|
type osResolverStub struct {
|
|
addr string
|
|
|
|
mu sync.Mutex
|
|
queries []string
|
|
}
|
|
|
|
// startOSResolverStub points osUpstreamConfig at a local DNS server for the
|
|
// duration of the test. It uses the legacy (plain UDP) resolver type because
|
|
// ResolverTypeOS resolves through process-wide state in the ctrld package that
|
|
// a test in this package cannot address.
|
|
func startOSResolverStub(t *testing.T) *osResolverStub {
|
|
t.Helper()
|
|
|
|
pc, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
|
|
require.NoError(t, err)
|
|
|
|
stub := &osResolverStub{addr: pc.LocalAddr().String()}
|
|
srv := &dns.Server{PacketConn: pc, Net: "udp"}
|
|
srv.Handler = dns.HandlerFunc(func(w dns.ResponseWriter, m *dns.Msg) {
|
|
stub.mu.Lock()
|
|
stub.queries = append(stub.queries, dns.TypeToString[m.Question[0].Qtype]+" "+m.Question[0].Name)
|
|
stub.mu.Unlock()
|
|
answer := new(dns.Msg)
|
|
answer.SetReply(m)
|
|
_ = w.WriteMsg(answer)
|
|
})
|
|
|
|
started := make(chan struct{})
|
|
srv.NotifyStartedFunc = func() { close(started) }
|
|
go func() { _ = srv.ActivateAndServe() }()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("timed out waiting for the OS resolver stub to start")
|
|
}
|
|
|
|
old := osUpstreamConfig
|
|
osUpstreamConfig = &ctrld.UpstreamConfig{
|
|
Name: "OS resolver",
|
|
Type: ctrld.ResolverTypeLegacy,
|
|
Endpoint: stub.addr,
|
|
Timeout: 2000,
|
|
}
|
|
t.Cleanup(func() {
|
|
osUpstreamConfig = old
|
|
_ = srv.Shutdown()
|
|
})
|
|
return stub
|
|
}
|
|
|
|
func (s *osResolverStub) received() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]string(nil), s.queries...)
|
|
}
|
|
|
|
// TestZPADNSEchoBypassReachesOSResolverUncached is the regression test for the
|
|
// two ways this route could look correct while still leaving ZPA broken.
|
|
//
|
|
// 1. It must not be served from ctrld's response cache. Client Connector
|
|
// enables Private Access only after it observes the echo answer arrive on
|
|
// its own DNS path; an answer we serve locally is silence to it. The nasty
|
|
// case is an entry cached while ZPA was disconnected, which would otherwise
|
|
// outlive InitializeOsResolver() on reconnect and keep Private Access
|
|
// disabled for the entry's whole TTL.
|
|
// 2. It must apply to every record type, not just A, matching the Control D
|
|
// bypass folder the customer confirmed as a workaround.
|
|
//
|
|
// It runs against three listener shapes, because the profile bypass list the
|
|
// customer is using today arrives as a listener domain rule with empty targets
|
|
// (see cfg.Listener rules built from resolverConfig.Exclude). Such a rule
|
|
// routes to the OS path on its own, so it would silently keep the cache — a
|
|
// machine upgraded while still carrying the workaround must get the fix too.
|
|
//
|
|
// A pre-existing upstream.os entry with a distinguishable rcode is seeded
|
|
// before every query, so a locally served answer is detectable.
|
|
func TestZPADNSEchoBypassReachesOSResolverUncached(t *testing.T) {
|
|
oldDNS64 := dns64NetworkClassFn
|
|
dns64NetworkClassFn = func() (bool, bool, error) { return true, false, nil }
|
|
t.Cleanup(func() { dns64NetworkClassFn = oldDNS64 })
|
|
|
|
listeners := []struct {
|
|
name string
|
|
rules []ctrld.Rule
|
|
}{
|
|
{"no-rule", nil},
|
|
{"exact-bypass-rule", []ctrld.Rule{{zpaDNSEchoDomain: []string{}}}},
|
|
{"wildcard-bypass-rule", []ctrld.Rule{{"*.zscaler.com": []string{}}}},
|
|
}
|
|
qtypes := []uint16{dns.TypeA, dns.TypeAAAA, dns.TypeTXT, dns.TypeHTTPS, dns.TypeMX}
|
|
for _, lcCase := range listeners {
|
|
for _, qtype := range qtypes {
|
|
for _, bypass := range []bool{true, false} {
|
|
name := lcCase.name + "/" + dns.TypeToString[qtype]
|
|
if bypass {
|
|
name += "/bypass"
|
|
} else {
|
|
name += "/no-bypass"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
withZPADNSEchoBypass(t, bypass, true, false)
|
|
stub := startOSResolverStub(t)
|
|
|
|
cfg := testhelper.SampleConfig(t)
|
|
cfg.Service.LeakOnUpstreamFailure = func(v bool) *bool { return &v }(false)
|
|
p := &prog{cfg: cfg, um: newUpstreamMonitor(cfg)}
|
|
for _, nc := range p.cfg.Network {
|
|
for _, cidr := range nc.Cidrs {
|
|
_, ipNet, err := net.ParseCIDR(cidr)
|
|
require.NoError(t, err)
|
|
nc.IPNets = append(nc.IPNets, ipNet)
|
|
}
|
|
}
|
|
cache, err := dnscache.NewLRUCache(64)
|
|
require.NoError(t, err)
|
|
p.cache = cache
|
|
|
|
lc := p.cfg.Listener["0"]
|
|
if lcCase.rules != nil {
|
|
lc = listenerWithRules(lcCase.rules...)
|
|
}
|
|
|
|
msg := newDnsMsgWithHostname(zpaDNSEchoDomain+".", qtype)
|
|
// Every candidate upstream has a fresh REFUSED answer
|
|
// waiting. The stub answers NOERROR, so the rcode says
|
|
// whether we went to the wire or were served from cache.
|
|
seedCache := func() {
|
|
for _, upstream := range []string{upstreamOS, "upstream.0", "upstream.1", "upstream.2"} {
|
|
cached := new(dns.Msg)
|
|
cached.SetRcode(msg, dns.RcodeRefused)
|
|
p.cache.Add(dnscache.NewKey(msg, upstream), dnscache.NewValue(cached, time.Now().Add(time.Hour)))
|
|
}
|
|
}
|
|
|
|
addr, err := net.ResolveUDPAddr("udp", "192.168.0.1:0")
|
|
require.NoError(t, err)
|
|
ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, requestID())
|
|
|
|
// Query twice. The second pass is what a Client Connector
|
|
// poll after a ZPA reconnect looks like: if the first answer
|
|
// had been cached, this one would never reach the resolver.
|
|
for pass := 1; pass <= 2; pass++ {
|
|
seedCache()
|
|
ufr := p.upstreamFor(ctx, "0", lc, addr, "", zpaDNSEchoDomain)
|
|
assert.Equal(t, bypass, ufr.zpaDNSEcho)
|
|
res := p.proxy(ctx, &proxyRequest{msg: msg, ufr: ufr})
|
|
|
|
require.NotNil(t, res)
|
|
require.NotNil(t, res.answer)
|
|
if bypass {
|
|
assert.False(t, res.cached, "pass %d: the echo query must not be answered from cache", pass)
|
|
assert.Equal(t, dns.RcodeSuccess, res.answer.Rcode,
|
|
"pass %d: %s echo query must be answered by the OS resolver", pass, dns.TypeToString[qtype])
|
|
} else {
|
|
assert.True(t, res.cached, "pass %d: an ordinary query still uses the cache", pass)
|
|
assert.Equal(t, dns.RcodeRefused, res.answer.Rcode,
|
|
"pass %d: %s echo query must not reach the OS resolver when the bypass is off", pass, dns.TypeToString[qtype])
|
|
}
|
|
}
|
|
|
|
got := stub.received()
|
|
want := dns.TypeToString[qtype] + " " + zpaDNSEchoDomain + "."
|
|
if bypass {
|
|
assert.Equal(t, []string{want, want}, got,
|
|
"the OS resolver must see the echo query on every poll")
|
|
// Write side: nothing may be stored under the OS
|
|
// upstream either, or the next poll would be local.
|
|
cached := p.cache.Get(dnscache.NewKey(msg, upstreamOS))
|
|
require.NotNil(t, cached, "seeded entry disappeared")
|
|
assert.Equal(t, dns.RcodeRefused, cached.Msg.Rcode,
|
|
"the echo answer must not be written to the cache")
|
|
} else {
|
|
assert.Empty(t, got, "the OS resolver must not be queried when the bypass is off")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCache(t *testing.T) {
|
|
cfg := testhelper.SampleConfig(t)
|
|
prog := &prog{cfg: cfg}
|
|
for _, nc := range prog.cfg.Network {
|
|
for _, cidr := range nc.Cidrs {
|
|
_, ipNet, err := net.ParseCIDR(cidr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nc.IPNets = append(nc.IPNets, ipNet)
|
|
}
|
|
}
|
|
cacher, err := dnscache.NewLRUCache(4096)
|
|
require.NoError(t, err)
|
|
prog.cache = cacher
|
|
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("example.com", dns.TypeA)
|
|
msg.MsgHdr.RecursionDesired = true
|
|
answer1 := new(dns.Msg)
|
|
answer1.SetRcode(msg, dns.RcodeSuccess)
|
|
|
|
prog.cache.Add(dnscache.NewKey(msg, "upstream.1"), dnscache.NewValue(answer1, time.Now().Add(time.Minute)))
|
|
answer2 := new(dns.Msg)
|
|
answer2.SetRcode(msg, dns.RcodeRefused)
|
|
prog.cache.Add(dnscache.NewKey(msg, "upstream.0"), dnscache.NewValue(answer2, time.Now().Add(time.Minute)))
|
|
|
|
req1 := &proxyRequest{
|
|
msg: msg,
|
|
ci: nil,
|
|
failoverRcodes: nil,
|
|
ufr: &upstreamForResult{
|
|
upstreams: []string{"upstream.1"},
|
|
matchedPolicy: "",
|
|
matchedNetwork: "",
|
|
matchedRule: "",
|
|
matched: false,
|
|
},
|
|
}
|
|
req2 := &proxyRequest{
|
|
msg: msg,
|
|
ci: nil,
|
|
failoverRcodes: nil,
|
|
ufr: &upstreamForResult{
|
|
upstreams: []string{"upstream.0"},
|
|
matchedPolicy: "",
|
|
matchedNetwork: "",
|
|
matchedRule: "",
|
|
matched: false,
|
|
},
|
|
}
|
|
got1 := prog.proxy(context.Background(), req1)
|
|
got2 := prog.proxy(context.Background(), req2)
|
|
assert.NotSame(t, got1, got2)
|
|
assert.Equal(t, answer1.Rcode, got1.answer.Rcode)
|
|
assert.Equal(t, answer2.Rcode, got2.answer.Rcode)
|
|
}
|
|
|
|
func TestDNS64CacheLookup(t *testing.T) {
|
|
cfg := testhelper.SampleConfig(t)
|
|
p := &prog{cfg: cfg}
|
|
cache, err := dnscache.NewLRUCache(16)
|
|
require.NoError(t, err)
|
|
p.cache = cache
|
|
|
|
now := time.Now()
|
|
prefix := dns64WellKnownPrefix
|
|
req := mkAAAAReq("legacy.example")
|
|
upstream := "upstream.0"
|
|
empty := new(dns.Msg)
|
|
empty.SetReply(req)
|
|
synthesized := new(dns.Msg)
|
|
synthesized.SetReply(req)
|
|
synthesized.Answer = []dns.RR{&dns.AAAA{Hdr: dns.RR_Header{Name: req.Question[0].Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 60}, AAAA: net.ParseIP("64:ff9b::c000:201")}}
|
|
|
|
t.Run("fresh variant hit", func(t *testing.T) {
|
|
p.cache.Purge()
|
|
p.cache.Add(dns64CacheKey(req, upstream, prefix), dnscache.NewValue(synthesized, now.Add(time.Minute)))
|
|
answer, stale, hit, dns64Hit, bypass := p.cachedResponse(req, upstream, prefix, true, now)
|
|
if answer == nil || !answerHasAAAA(answer) || stale != nil || !hit || !dns64Hit || bypass {
|
|
t.Fatalf("unexpected lookup result: answer=%v stale=%v hit=%v dns64Hit=%v bypass=%v", answer, stale, hit, dns64Hit, bypass)
|
|
}
|
|
})
|
|
|
|
t.Run("fresh empty normal answer is retained as stale while bypassed", func(t *testing.T) {
|
|
p.cache.Purge()
|
|
p.cache.Add(dnscache.NewKey(req, upstream), dnscache.NewValue(empty, now.Add(time.Minute)))
|
|
answer, stale, hit, dns64Hit, bypass := p.cachedResponse(req, upstream, prefix, true, now)
|
|
if answer != nil || stale == nil || hit || dns64Hit || !bypass {
|
|
t.Fatalf("unexpected lookup result: answer=%v stale=%v hit=%v dns64Hit=%v bypass=%v", answer, stale, hit, dns64Hit, bypass)
|
|
}
|
|
})
|
|
|
|
t.Run("expired variant is preferred as stale", func(t *testing.T) {
|
|
p.cache.Purge()
|
|
p.cache.Add(dns64CacheKey(req, upstream, prefix), dnscache.NewValue(synthesized, now.Add(-time.Minute)))
|
|
p.cache.Add(dnscache.NewKey(req, upstream), dnscache.NewValue(empty, now.Add(-time.Minute)))
|
|
answer, stale, hit, dns64Hit, bypass := p.cachedResponse(req, upstream, prefix, true, now)
|
|
if answer != nil || stale == nil || !answerHasAAAA(stale) || hit || dns64Hit || bypass {
|
|
t.Fatalf("unexpected lookup result: answer=%v stale=%v hit=%v dns64Hit=%v bypass=%v", answer, stale, hit, dns64Hit, bypass)
|
|
}
|
|
})
|
|
}
|
|
|
|
func Test_ipAndMacFromMsg(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ip string
|
|
wantIp bool
|
|
mac string
|
|
wantMac bool
|
|
}{
|
|
{"has ip v4 and mac", "1.2.3.4", true, "4c:20:b8:ab:87:1b", true},
|
|
{"has ip v6 and mac", "2606:1a40:3::1", true, "4c:20:b8:ab:87:1b", true},
|
|
{"no ip", "1.2.3.4", false, "4c:20:b8:ab:87:1b", false},
|
|
{"no mac", "1.2.3.4", false, "4c:20:b8:ab:87:1b", false},
|
|
}
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ip := net.ParseIP(tc.ip)
|
|
if ip == nil {
|
|
t.Fatal("missing IP")
|
|
}
|
|
hw, err := net.ParseMAC(tc.mac)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := new(dns.Msg)
|
|
m.SetQuestion("example.com.", dns.TypeA)
|
|
o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
|
|
if tc.wantMac {
|
|
ec1 := &dns.EDNS0_LOCAL{Code: EDNS0_OPTION_MAC, Data: hw}
|
|
o.Option = append(o.Option, ec1)
|
|
}
|
|
if tc.wantIp {
|
|
ec2 := &dns.EDNS0_SUBNET{Address: ip}
|
|
o.Option = append(o.Option, ec2)
|
|
}
|
|
m.Extra = append(m.Extra, o)
|
|
gotIP, gotMac := ipAndMacFromMsg(m)
|
|
if tc.wantMac && gotMac != tc.mac {
|
|
t.Errorf("mismatch, want: %q, got: %q", tc.mac, gotMac)
|
|
}
|
|
if !tc.wantMac && gotMac != "" {
|
|
t.Errorf("unexpected mac: %q", gotMac)
|
|
}
|
|
if tc.wantIp && gotIP != tc.ip {
|
|
t.Errorf("mismatch, want: %q, got: %q", tc.ip, gotIP)
|
|
}
|
|
if !tc.wantIp && gotIP != "" {
|
|
t.Errorf("unexpected ip: %q", gotIP)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_remoteAddrFromMsg(t *testing.T) {
|
|
loopbackIP := net.ParseIP("127.0.0.1")
|
|
tests := []struct {
|
|
name string
|
|
addr net.Addr
|
|
ci *ctrld.ClientInfo
|
|
want string
|
|
}{
|
|
{"tcp", &net.TCPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{IP: "192.168.1.10"}, "192.168.1.10:12345"},
|
|
{"udp", &net.UDPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{IP: "192.168.1.11"}, "192.168.1.11:12345"},
|
|
{"nil client info", &net.UDPAddr{IP: loopbackIP, Port: 12345}, nil, "127.0.0.1:12345"},
|
|
{"empty ip", &net.UDPAddr{IP: loopbackIP, Port: 12345}, &ctrld.ClientInfo{}, "127.0.0.1:12345"},
|
|
}
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
addr := spoofRemoteAddr(tc.addr, tc.ci)
|
|
if addr.String() != tc.want {
|
|
t.Errorf("unexpected result, want: %q, got: %q", tc.want, addr.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_ipFromARPA(t *testing.T) {
|
|
tests := []struct {
|
|
IP string
|
|
ARPA string
|
|
}{
|
|
{"1.2.3.4", "4.3.2.1.in-addr.arpa."},
|
|
{"245.110.36.114", "114.36.110.245.in-addr.arpa."},
|
|
{"::ffff:12.34.56.78", "78.56.34.12.in-addr.arpa."},
|
|
{"::1", "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa."},
|
|
{"1::", "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.1.0.0.0.ip6.arpa."},
|
|
{"1234:567::89a:bcde", "e.d.c.b.a.9.8.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.7.6.5.0.4.3.2.1.ip6.arpa."},
|
|
{"1234:567:fefe:bcbc:adad:9e4a:89a:bcde", "e.d.c.b.a.9.8.0.a.4.e.9.d.a.d.a.c.b.c.b.e.f.e.f.7.6.5.0.4.3.2.1.ip6.arpa."},
|
|
{"", "asd.in-addr.arpa."},
|
|
{"", "asd.ip6.arpa."},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.IP, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := ipFromARPA(tc.ARPA); !got.Equal(net.ParseIP(tc.IP)) {
|
|
t.Errorf("unexpected ip, want: %s, got: %s", tc.IP, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newDnsMsgWithClientIP(ip string) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetQuestion("example.com.", dns.TypeA)
|
|
o := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
|
|
o.Option = append(o.Option, &dns.EDNS0_SUBNET{Address: net.ParseIP(ip)})
|
|
m.Extra = append(m.Extra, o)
|
|
return m
|
|
}
|
|
|
|
func Test_stripClientSubnet(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msg *dns.Msg
|
|
wantSubnet bool
|
|
}{
|
|
{"no edns0", new(dns.Msg), false},
|
|
{"loopback IP v4", newDnsMsgWithClientIP("127.0.0.1"), false},
|
|
{"loopback IP v6", newDnsMsgWithClientIP("::1"), false},
|
|
{"private IP v4", newDnsMsgWithClientIP("192.168.1.123"), false},
|
|
{"private IP v6", newDnsMsgWithClientIP("fd12:3456:789a:1::1"), false},
|
|
{"public IP", newDnsMsgWithClientIP("1.1.1.1"), true},
|
|
{"invalid IP", newDnsMsgWithClientIP(""), true},
|
|
}
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
stripClientSubnet(tc.msg)
|
|
hasSubnet := false
|
|
if opt := tc.msg.IsEdns0(); opt != nil {
|
|
for _, s := range opt.Option {
|
|
if _, ok := s.(*dns.EDNS0_SUBNET); ok {
|
|
hasSubnet = true
|
|
}
|
|
}
|
|
}
|
|
if tc.wantSubnet != hasSubnet {
|
|
t.Errorf("unexpected result, want: %v, got: %v", tc.wantSubnet, hasSubnet)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newDnsMsgWithHostname(hostname string, typ uint16) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(hostname, typ)
|
|
return m
|
|
}
|
|
|
|
func Test_isLanHostnameQuery(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msg *dns.Msg
|
|
isLanHostnameQuery bool
|
|
}{
|
|
{"A", newDnsMsgWithHostname("foo", dns.TypeA), true},
|
|
{"AAAA", newDnsMsgWithHostname("foo", dns.TypeAAAA), true},
|
|
{"A not LAN", newDnsMsgWithHostname("example.com", dns.TypeA), false},
|
|
{"AAAA not LAN", newDnsMsgWithHostname("example.com", dns.TypeAAAA), false},
|
|
{"Not A or AAAA", newDnsMsgWithHostname("foo", dns.TypeTXT), false},
|
|
{".domain", newDnsMsgWithHostname("foo.domain", dns.TypeA), true},
|
|
{".lan", newDnsMsgWithHostname("foo.lan", dns.TypeA), true},
|
|
{".local", newDnsMsgWithHostname("foo.local", dns.TypeA), true},
|
|
}
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := isLanHostnameQuery(tc.msg); tc.isLanHostnameQuery != got {
|
|
t.Errorf("unexpected result, want: %v, got: %v", tc.isLanHostnameQuery, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newDnsMsgPtr(ip string, t *testing.T) *dns.Msg {
|
|
t.Helper()
|
|
m := new(dns.Msg)
|
|
ptr, err := dns.ReverseAddr(ip)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.SetQuestion(ptr, dns.TypePTR)
|
|
return m
|
|
}
|
|
|
|
func Test_isPrivatePtrLookup(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msg *dns.Msg
|
|
isPrivatePtrLookup bool
|
|
}{
|
|
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
|
|
{"10.0.0.0/8", newDnsMsgPtr("10.0.0.123", t), true},
|
|
{"172.16.0.0/12", newDnsMsgPtr("172.16.0.123", t), true},
|
|
{"192.168.0.0/16", newDnsMsgPtr("192.168.1.123", t), true},
|
|
{"CGNAT", newDnsMsgPtr("100.66.27.28", t), true},
|
|
{"Loopback", newDnsMsgPtr("127.0.0.1", t), true},
|
|
{"Link Local Unicast", newDnsMsgPtr("fe80::69f6:e16e:8bdb:433f", t), true},
|
|
// RFC 7335 IPv4 Service Continuity Prefix (464XLAT/DS-Lite CLAT), see #552.
|
|
{"464XLAT CLAT host", newDnsMsgPtr("192.0.0.2", t), true},
|
|
{"Public IP", newDnsMsgPtr("8.8.8.8", t), false},
|
|
}
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := isPrivatePtrLookup(tc.msg); tc.isPrivatePtrLookup != got {
|
|
t.Errorf("unexpected result, want: %v, got: %v", tc.isPrivatePtrLookup, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_isSrvLanLookup(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msg *dns.Msg
|
|
isSrvLookup bool
|
|
}{
|
|
{"SRV LAN", newDnsMsgWithHostname("foo", dns.TypeSRV), true},
|
|
{"Not SRV", newDnsMsgWithHostname("foo", dns.TypeNone), false},
|
|
{"Not SRV LAN", newDnsMsgWithHostname("controld.com", dns.TypeSRV), false},
|
|
}
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := isSrvLanLookup(tc.msg); tc.isSrvLookup != got {
|
|
t.Errorf("unexpected result, want: %v, got: %v", tc.isSrvLookup, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_isWanClient(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
addr net.Addr
|
|
isWanClient bool
|
|
}{
|
|
// RFC 1918 allocates 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16 as
|
|
{"10.0.0.0/8", &net.UDPAddr{IP: net.ParseIP("10.0.0.123")}, false},
|
|
{"172.16.0.0/12", &net.UDPAddr{IP: net.ParseIP("172.16.0.123")}, false},
|
|
{"192.168.0.0/16", &net.UDPAddr{IP: net.ParseIP("192.168.1.123")}, false},
|
|
{"CGNAT", &net.UDPAddr{IP: net.ParseIP("100.66.27.28")}, false},
|
|
{"Loopback", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, false},
|
|
{"Link Local Unicast", &net.UDPAddr{IP: net.ParseIP("fe80::69f6:e16e:8bdb:433f")}, false},
|
|
// RFC 7335 IPv4 Service Continuity Prefix (464XLAT/DS-Lite CLAT), see #552.
|
|
{"464XLAT PLAT side", &net.UDPAddr{IP: net.ParseIP("192.0.0.1")}, false},
|
|
{"464XLAT CLAT host", &net.UDPAddr{IP: net.ParseIP("192.0.0.2")}, false},
|
|
// Outside the /29 but inside 192.0.0.0/24: still WAN (fix is scoped to /29).
|
|
{"192.0.0.0/24 outside /29", &net.UDPAddr{IP: net.ParseIP("192.0.0.100")}, true},
|
|
{"Public", &net.UDPAddr{IP: net.ParseIP("8.8.8.8")}, true},
|
|
}
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := isWanClient(tc.addr); tc.isWanClient != got {
|
|
t.Errorf("unexpected result, want: %v, got: %v", tc.isWanClient, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_prog_queryFromSelf(t *testing.T) {
|
|
p := &prog{}
|
|
require.NotPanics(t, func() {
|
|
p.queryFromSelf("")
|
|
})
|
|
require.NotPanics(t, func() {
|
|
p.queryFromSelf("foo")
|
|
})
|
|
}
|
|
|
|
func Test_sameQuestion(t *testing.T) {
|
|
mk := func(name string, qtype uint16) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(name, qtype)
|
|
return m
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
req *dns.Msg
|
|
answer *dns.Msg
|
|
want bool
|
|
}{
|
|
{"identical", mk("example.com.", dns.TypeA), mk("example.com.", dns.TypeA), true},
|
|
{"case insensitive", mk("Example.COM.", dns.TypeA), mk("example.com.", dns.TypeA), true},
|
|
{"different name", mk("victim.example.", dns.TypeA), mk("attacker.example.", dns.TypeA), false},
|
|
{"different type", mk("example.com.", dns.TypeA), mk("example.com.", dns.TypeAAAA), false},
|
|
{"nil req", nil, mk("example.com.", dns.TypeA), false},
|
|
{"nil answer", mk("example.com.", dns.TypeA), nil, false},
|
|
{"empty answer question", mk("example.com.", dns.TypeA), new(dns.Msg), false},
|
|
}
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := sameQuestion(tc.req, tc.answer); got != tc.want {
|
|
t.Errorf("sameQuestion() = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|