From b916a30a74613ecffc8d00713c97da2a7a02c25c Mon Sep 17 00:00:00 2001 From: Dave McCormick Date: Mon, 16 Sep 2024 16:11:08 +0100 Subject: [PATCH] Add AXFR tests to kubernetai plugin. Share AXFR testing tools from the kubernetes plugin. Signed-off-by: Dave McCormick --- test/kubernetai/axfr_test.go | 200 +++++++++++++++++++++++++++++++++++ test/kubernetes/axfr_test.go | 126 ++-------------------- test/kubernetes/tools.go | 139 +++++++++++++++++++++++- 3 files changed, 343 insertions(+), 122 deletions(-) create mode 100644 test/kubernetai/axfr_test.go diff --git a/test/kubernetai/axfr_test.go b/test/kubernetai/axfr_test.go new file mode 100644 index 0000000..27a1bb8 --- /dev/null +++ b/test/kubernetai/axfr_test.go @@ -0,0 +1,200 @@ +package kubernetai + +import ( + "bufio" + "fmt" + "strings" + "testing" + + "github.com/coredns/ci/test/kubernetes" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +// load answers turns a text based dig response into a set of answers +func loadAXFRAnswers(t *testing.T, results string) []dns.RR { + s := bufio.NewScanner(strings.NewReader(results)) + answers, err := kubernetes.ParseDigAXFR(s) + if err != nil { + t.Fatalf("failed to parse expected AXFR results: %v", err) + return []dns.RR{} + } + return answers.Answer +} + +func TestAXFR(t *testing.T) { + testCases := map[string]struct { + Config string + dig test.Case + }{ + "matches stanza 1": { + Config: ` .:53 { + health + ready + errors + log + kubernetai test-4.svc.cluster.local 10.in-addr.arpa { + namespaces test-4 + fallthrough + } + kubernetai test-5.svc.cluster.local 10.in-addr.arpa { + namespaces test-5 + pods verified + endpoint_pod_names + } + transfer { + to * + } + } +`, + dig: test.Case{ + Qname: "test-4.svc.cluster.local.", Qtype: dns.TypeAXFR, + Rcode: dns.RcodeSuccess, + Answer: loadAXFRAnswers(t, ` +test-4.svc.cluster.local. 5 IN SOA ns.dns.test-4.svc.cluster.local. hostmaster.test-4.svc.cluster.local. 1726484129 7200 1800 86400 5 +ext-svc.test-4.svc.test-4.svc.cluster.local. 5 IN CNAME example.net. +headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN A 172.17.0.252 +172-17-0-252.headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN A 172.17.0.252 +_c-port._udp.headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 50 1234 172-17-0-252.headless-svc.test-4.svc.test-4.svc.cluster.local. +headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN A 172.17.0.253 +172-17-0-253.headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN A 172.17.0.253 +_c-port._udp.headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 50 1234 172-17-0-253.headless-svc.test-4.svc.test-4.svc.cluster.local. +headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN AAAA 1234:abcd::3 +1234-abcd--3.headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN AAAA 1234:abcd::3 +_c-port._udp.headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 50 1234 1234-abcd--3.headless-svc.test-4.svc.test-4.svc.cluster.local. +headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN AAAA 1234:abcd::4 +1234-abcd--4.headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN AAAA 1234:abcd::4 +_c-port._udp.headless-svc.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 50 1234 1234-abcd--4.headless-svc.test-4.svc.test-4.svc.cluster.local. +svc-1-a.test-4.svc.test-4.svc.cluster.local. 5 IN A 10.96.0.200 +svc-1-a.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 100 80 svc-1-a.test-4.svc.test-4.svc.cluster.local. +_http._tcp.svc-1-a.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 100 80 svc-1-a.test-4.svc.test-4.svc.cluster.local. +svc-1-a.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 100 443 svc-1-a.test-4.svc.test-4.svc.cluster.local. +_https._tcp.svc-1-a.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 100 443 svc-1-a.test-4.svc.test-4.svc.cluster.local. +svc-1-b.test-4.svc.test-4.svc.cluster.local. 5 IN A 10.96.0.210 +svc-1-b.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 100 80 svc-1-b.test-4.svc.test-4.svc.cluster.local. +_http._tcp.svc-1-b.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 100 80 svc-1-b.test-4.svc.test-4.svc.cluster.local. +svc-c.test-4.svc.test-4.svc.cluster.local. 5 IN A 10.96.0.215 +svc-c.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 100 1234 svc-c.test-4.svc.test-4.svc.cluster.local. +_c-port._udp.svc-c.test-4.svc.test-4.svc.cluster.local. 5 IN SRV 0 100 1234 svc-c.test-4.svc.test-4.svc.cluster.local. +test-4.svc.cluster.local. 5 IN SOA ns.dns.test-4.svc.cluster.local. hostmaster.test-4.svc.cluster.local. 1726484129 7200 1800 86400 5 +`), + }, + }, + "matches stanza 2": { + Config: ` .:53 { + health + ready + errors + log + kubernetai test-4.svc.cluster.local 10.in-addr.arpa { + namespaces test-4 + fallthrough + } + kubernetai test-5.svc.cluster.local 10.in-addr.arpa { + namespaces test-5 + pods verified + endpoint_pod_names + } + transfer { + to * + } + } +`, + dig: test.Case{ + Qname: "test-5.svc.cluster.local.", Qtype: dns.TypeAXFR, + Rcode: dns.RcodeSuccess, + Answer: loadAXFRAnswers(t, ` +test-5.svc.cluster.local. 5 IN SOA ns.dns.test-5.svc.cluster.local. hostmaster.test-5.svc.cluster.local. 1726484386 7200 1800 86400 5 +headless-1.test-5.svc.test-5.svc.cluster.local. 5 IN A 172.17.0.173 +test-name.headless-1.test-5.svc.test-5.svc.cluster.local. 5 IN A 172.17.0.173 +_http._tcp.headless-1.test-5.svc.test-5.svc.cluster.local. 5 IN SRV 0 100 80 test-name.headless-1.test-5.svc.test-5.svc.cluster.local. +headless-2.test-5.svc.test-5.svc.cluster.local. 5 IN A 172.17.0.182 +172-17-0-182.headless-2.test-5.svc.test-5.svc.cluster.local. 5 IN A 172.17.0.182 +_http._tcp.headless-2.test-5.svc.test-5.svc.cluster.local. 5 IN SRV 0 100 80 172-17-0-182.headless-2.test-5.svc.test-5.svc.cluster.local. +test-5.svc.cluster.local. 5 IN SOA ns.dns.test-5.svc.cluster.local. hostmaster.test-5.svc.cluster.local. 1726484386 7200 1800 86400 5 +`), + }, + }, + "matches first stanza": { + Config: ` .:53 { + health + ready + errors + log + kubernetai cluster.local 10.in-addr.arpa { + namespaces test-4 + fallthrough + } + kubernetai cluster.local 10.in-addr.arpa { + namespaces test-5 + pods verified + endpoint_pod_names + } + transfer { + to * + } + } +`, + dig: test.Case{ + Qname: "cluster.local.", Qtype: dns.TypeAXFR, + Rcode: dns.RcodeSuccess, + Answer: loadAXFRAnswers(t, ` +cluster.local. 5 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1726499016 7200 1800 86400 5 +ext-svc.test-4.svc.cluster.local. 5 IN CNAME example.net. +headless-svc.test-4.svc.cluster.local. 5 IN AAAA 1234:abcd::3 +1234-abcd--3.headless-svc.test-4.svc.cluster.local. 5 IN AAAA 1234:abcd::3 +_c-port._udp.headless-svc.test-4.svc.cluster.local. 5 IN SRV 0 50 1234 1234-abcd--3.headless-svc.test-4.svc.cluster.local. +headless-svc.test-4.svc.cluster.local. 5 IN AAAA 1234:abcd::4 +1234-abcd--4.headless-svc.test-4.svc.cluster.local. 5 IN AAAA 1234:abcd::4 +_c-port._udp.headless-svc.test-4.svc.cluster.local. 5 IN SRV 0 50 1234 1234-abcd--4.headless-svc.test-4.svc.cluster.local. +headless-svc.test-4.svc.cluster.local. 5 IN A 172.17.0.249 +172-17-0-249.headless-svc.test-4.svc.cluster.local. 5 IN A 172.17.0.249 +_c-port._udp.headless-svc.test-4.svc.cluster.local. 5 IN SRV 0 50 1234 172-17-0-249.headless-svc.test-4.svc.cluster.local. +headless-svc.test-4.svc.cluster.local. 5 IN A 172.17.0.250 +172-17-0-250.headless-svc.test-4.svc.cluster.local. 5 IN A 172.17.0.250 +_c-port._udp.headless-svc.test-4.svc.cluster.local. 5 IN SRV 0 50 1234 172-17-0-250.headless-svc.test-4.svc.cluster.local. +svc-1-a.test-4.svc.cluster.local. 5 IN A 10.96.0.200 +svc-1-a.test-4.svc.cluster.local. 5 IN SRV 0 100 80 svc-1-a.test-4.svc.cluster.local. +_http._tcp.svc-1-a.test-4.svc.cluster.local. 5 IN SRV 0 100 80 svc-1-a.test-4.svc.cluster.local. +svc-1-a.test-4.svc.cluster.local. 5 IN SRV 0 100 443 svc-1-a.test-4.svc.cluster.local. +_https._tcp.svc-1-a.test-4.svc.cluster.local. 5 IN SRV 0 100 443 svc-1-a.test-4.svc.cluster.local. +svc-1-b.test-4.svc.cluster.local. 5 IN A 10.96.0.210 +svc-1-b.test-4.svc.cluster.local. 5 IN SRV 0 100 80 svc-1-b.test-4.svc.cluster.local. +_http._tcp.svc-1-b.test-4.svc.cluster.local. 5 IN SRV 0 100 80 svc-1-b.test-4.svc.cluster.local. +svc-c.test-4.svc.cluster.local. 5 IN A 10.96.0.215 +svc-c.test-4.svc.cluster.local. 5 IN SRV 0 100 1234 svc-c.test-4.svc.cluster.local. +_c-port._udp.svc-c.test-4.svc.cluster.local. 5 IN SRV 0 100 1234 svc-c.test-4.svc.cluster.local. +cluster.local. 5 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1726499016 7200 1800 86400 5`), + }, + }, + } + + for description, tc := range testCases { + t.Run(fmt.Sprintf("%s/%s %s", description, tc.dig.Qname, dns.TypeToString[tc.dig.Qtype]), func(t *testing.T) { + err := kubernetes.LoadCorefile(tc.Config) + if err != nil { + t.Fatalf("Could not load corefile: %s", err) + } + namespace := "test-1" + err = kubernetes.StartClientPod(namespace) + if err != nil { + t.Fatalf("failed to start client pod: %s", err) + } + + res, err := kubernetes.DoIntegrationTest(tc.dig, namespace) + if err != nil { + t.Errorf(err.Error()) + } + if res != nil { + failures := kubernetes.ValidateAXFR(res.Answer, tc.dig.Answer) + for _, flr := range failures { + t.Error(flr) + } + } + if t.Failed() { + t.Errorf("coredns log: %s", kubernetes.CorednsLogs()) + } + }) + } +} diff --git a/test/kubernetes/axfr_test.go b/test/kubernetes/axfr_test.go index e71b649..ba89b72 100644 --- a/test/kubernetes/axfr_test.go +++ b/test/kubernetes/axfr_test.go @@ -3,7 +3,6 @@ package kubernetes import ( "bufio" "fmt" - "regexp" "strings" "testing" @@ -15,7 +14,7 @@ import ( // load answers turns a text based dig response into a set of answers func loadAXFRAnswers(t *testing.T, results string) []dns.RR { s := bufio.NewScanner(strings.NewReader(results)) - answers, err := parseDigAXFR(s) + answers, err := ParseDigAXFR(s) if err != nil { t.Fatalf("failed to parse expected AXFR results: %v", err) return []dns.RR{} @@ -96,7 +95,10 @@ cluster.local. 5 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1726438 t.Errorf(err.Error()) } if res != nil { - validateAXFR(t, res.Answer, tc.Answer) + failures := ValidateAXFR(res.Answer, tc.Answer) + for _, flr := range failures { + t.Error(flr) + } } if t.Failed() { t.Errorf("coredns log: %s", CorednsLogs()) @@ -162,7 +164,10 @@ cluster.local. 5 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1726233 t.Errorf(err.Error()) } if res != nil { - validateAXFR(t, res.Answer, tc.Answer) + failures := ValidateAXFR(res.Answer, tc.Answer) + for _, flr := range failures { + t.Error(flr) + } } if t.Failed() { t.Errorf("coredns log: %s", CorednsLogs()) @@ -170,116 +175,3 @@ cluster.local. 5 IN SOA ns.dns.cluster.local. hostmaster.cluster.local. 1726233 }) } } - -// validateAXFR compares the dns records returned against a set of expected records. -// It ensures that the axfr response begins and ends with an SOA record. -// It will only test the first 3 tuples of each A record. -func validateAXFR(t *testing.T, xfr []dns.RR, expected []dns.RR) { - if xfr[0].Header().Rrtype != dns.TypeSOA { - t.Error("Invalid transfer response, does not start with SOA record") - } - if xfr[len(xfr)-1].Header().Rrtype != dns.TypeSOA { - t.Error("Invalid transfer response, does not end with SOA record") - } - - // make a map of xfr responses to search... - xfrMap := make(map[int]dns.RR, len(xfr)) - for i := range xfr { - xfrMap[i] = xfr[i] - } - - // for each expected entry find a result response which matches. - for i := range expected { - matched := false - for key, resultRR := range xfrMap { - if !matchHeader(t, expected[i].Header(), resultRR.Header()) { - continue - } - - // headers match - // special matchers and default full match - switch expected[i].Header().Rrtype { - case dns.TypeSOA, dns.TypeA: - matched = true - break - case dns.TypeSRV: - if matchSRVResponse(t, expected[i].(*dns.SRV), resultRR.(*dns.SRV)) { - matched = true - } - break - default: - if dns.IsDuplicate(expected[i], resultRR) { - matched = true - } - } - - if matched { - delete(xfrMap, key) - break - } - } - if !matched { - t.Errorf("this AXFR record does not match any results:\n%s\n", expected[i]) - } - } - - if len(xfr) > len(expected) { - t.Errorf("Invalid number of responses, want %d, got %d", len(expected), len(xfr)) - } -} - -// matchHeader will return true when two headers are exactly equal or the expected and resultant header -// both contain a dashed ip address and the domain matches. -func matchHeader(t *testing.T, expected, result *dns.RR_Header) bool { - if expected.Rrtype != result.Rrtype { - return false - } - if expected.Class != result.Class { - return false - } - if expected.Rrtype != result.Rrtype { - return false - } - expectedNameReg, err := zoneToRelaxedRegex(expected.Name) - if err != nil { - t.Fatalf("failed to covert dns name %s to regex: %v", expected.Name, err) - } - if !expectedNameReg.MatchString(result.Name) { - return false - } - return true -} - -// validateSRVResponse matches an SRV response record -func matchSRVResponse(t *testing.T, expectedSRV, resultSRV *dns.SRV) bool { - expectedTargetReg, err := zoneToRelaxedRegex(expectedSRV.Target) - if err != nil { - t.Fatalf("failed to covert srv target %s to regex: %v", expectedSRV.Target, err) - } - if !expectedTargetReg.MatchString(resultSRV.Target) { - return false - } - - // test other SRV record attributes... - if expectedSRV.Port != resultSRV.Port { - return false - } - if expectedSRV.Priority != resultSRV.Priority { - return false - } - if expectedSRV.Weight != resultSRV.Weight { - return false - } - return true -} - -var ipPartMatcher = regexp.MustCompile(`^\d+-\d+-\d+-\d+\.`) - -// zoneToRelaxedRegex creates a regular expression from a domain name, replacing ipv4 dashed addresses with a -// more generalised matcher that will match any address. -func zoneToRelaxedRegex(source string) (*regexp.Regexp, error) { - if !ipPartMatcher.MatchString(source) { - return regexp.Compile(`^` + source + `$`) - } - return regexp.Compile(ipPartMatcher.ReplaceAllString(source, `^\d+-\d+-\d+-\d+\.`) + `$`) -} diff --git a/test/kubernetes/tools.go b/test/kubernetes/tools.go index 2d856cd..2ca93c7 100644 --- a/test/kubernetes/tools.go +++ b/test/kubernetes/tools.go @@ -7,6 +7,7 @@ import ( "net" "os" "os/exec" + "regexp" "sort" "strconv" "strings" @@ -29,7 +30,7 @@ func DoIntegrationTest(tc test.Case, namespace string) (*dns.Msg, error) { switch tc.Qtype { case dns.TypeAXFR: digCmd = "dig -t " + dns.TypeToString[tc.Qtype] + " " + tc.Qname + " +time=10 +tries=6" - dp = parseDigAXFR + dp = ParseDigAXFR default: digCmd = "dig -t " + dns.TypeToString[tc.Qtype] + " " + tc.Qname + " +search +showsearch +time=10 +tries=6" dp = parseDig @@ -393,7 +394,7 @@ func ParseDigResponse(r string, dp DigParser) ([]*dns.Msg, error) { } // DigParser is a function that specialises in parsing different responses from running dig. -// The regular parseDig parser is acceptable for most tests, whilst the parseDigAXFR handles this special case. +// The regular parseDig parser is acceptable for most tests, whilst the ParseDigAXFR handles this special case. type DigParser func(s *bufio.Scanner) (*dns.Msg, error) // parseDig parses a single dig-like response and returns a dns.Msg @@ -414,8 +415,8 @@ func parseDig(s *bufio.Scanner) (*dns.Msg, error) { return m, nil } -// parseDigAXFR specifically parses AXFR responses which have a different format. -func parseDigAXFR(s *bufio.Scanner) (*dns.Msg, error) { +// ParseDigAXFR specifically parses AXFR responses which have a different format. +func ParseDigAXFR(s *bufio.Scanner) (*dns.Msg, error) { m := new(dns.Msg) for s.Scan() { if s.Text() == "" { @@ -426,7 +427,7 @@ func parseDigAXFR(s *bufio.Scanner) (*dns.Msg, error) { } r, err := dns.NewRR(s.Text()) if err != nil { - fmt.Println("parseDigAXFR RR record could not be parsed") + fmt.Println("ParseDigAXFR RR record could not be parsed") return nil, err } m.Answer = append(m.Answer, r) @@ -559,3 +560,131 @@ example.net. IN A 13.14.15.16 CoreDNSLabel = "k8s-app=kube-dns" APIServerLabel = "component=kube-apiserver" ) + +// ValidateAXFR compares the dns records returned against a set of expected records. +// It ensures that the axfr response begins and ends with an SOA record. +// It will only test the first 3 tuples of each A record. +func ValidateAXFR(xfr []dns.RR, expected []dns.RR) []error { + var failures []error + if xfr[0].Header().Rrtype != dns.TypeSOA { + failures = append(failures, errors.New("Invalid transfer response, does not start with SOA record")) + } + if xfr[len(xfr)-1].Header().Rrtype != dns.TypeSOA { + failures = append(failures, errors.New("Invalid transfer response, does not end with SOA record")) + } + + // make a map of xfr responses to search... + xfrMap := make(map[int]dns.RR, len(xfr)) + for i := range xfr { + xfrMap[i] = xfr[i] + } + + // for each expected entry find a result response which matches. + for i := range expected { + matched := false + for key, resultRR := range xfrMap { + hmatch, err := matchHeader(expected[i].Header(), resultRR.Header()) + if err != nil { + failures = append(failures, err) + break + } + if !hmatch { + continue + } + + // headers match + // special matchers and default full match + switch expected[i].Header().Rrtype { + case dns.TypeSOA, dns.TypeA: + matched = true + break + case dns.TypeSRV: + srvMatch, err := matchSRVResponse(expected[i].(*dns.SRV), resultRR.(*dns.SRV)) + if err != nil { + failures = append(failures, err) + break + } + if srvMatch { + matched = true + break + } + default: + if dns.IsDuplicate(expected[i], resultRR) { + matched = true + } + } + + if matched { + delete(xfrMap, key) + break + } + } + if !matched { + failures = append(failures, fmt.Errorf("the expected AXFR record not found in results:\n%s\n", expected[i])) + } + } + + // catch unexpected extra records + if len(xfrMap) > 0 { + for _, r := range xfrMap { + failures = append(failures, fmt.Errorf("additional axfr record not expected: %s", r.String())) + } + } + return failures +} + +// matchHeader will return true when two headers are exactly equal or the expected and resultant header +// both contain a dashed ip address and the domain matches. +func matchHeader(expected, result *dns.RR_Header) (bool, error) { + if expected.Rrtype != result.Rrtype { + return false, nil + } + if expected.Class != result.Class { + return false, nil + } + if expected.Rrtype != result.Rrtype { + return false, nil + } + expectedNameReg, err := zoneToRelaxedRegex(expected.Name) + if err != nil { + return false, fmt.Errorf("failed to covert dns name %s to regex: %v", expected.Name, err) + } + if !expectedNameReg.MatchString(result.Name) { + return false, nil + } + return true, nil +} + +// validateSRVResponse matches an SRV response record +func matchSRVResponse(expectedSRV, resultSRV *dns.SRV) (bool, error) { + // test other SRV record attributes... + if expectedSRV.Port != resultSRV.Port { + return false, nil + } + if expectedSRV.Priority != resultSRV.Priority { + return false, nil + } + if expectedSRV.Weight != resultSRV.Weight { + return false, nil + } + + expectedTargetReg, err := zoneToRelaxedRegex(expectedSRV.Target) + if err != nil { + return false, fmt.Errorf("failed to covert srv target %s to regex: %v", expectedSRV.Target, err) + } + if !expectedTargetReg.MatchString(resultSRV.Target) { + return false, nil + } + return true, nil +} + +var ipPartMatcher = regexp.MustCompile(`^\d+-\d+-\d+-\d+\.`) + +// zoneToRelaxedRegex creates a regular expression from a domain name, replacing ipv4 dashed addresses with a +// more generalised matcher that will match any address. +func zoneToRelaxedRegex(source string) (*regexp.Regexp, error) { + if !ipPartMatcher.MatchString(source) { + return regexp.Compile(`^` + source + `$`) + } + return regexp.Compile(ipPartMatcher.ReplaceAllString(source, `^\d+-\d+-\d+-\d+\.`) + `$`) +}