diff --git a/config/config.go b/config/config.go index bd5c9dffc..95b542e1e 100644 --- a/config/config.go +++ b/config/config.go @@ -455,21 +455,30 @@ func loadConfig(logger *logrus.Entry, path string, mandatory bool) (rCfg *Config return nil, fmt.Errorf("can't read config file(s): %w", err) } - var data []byte + var ( + data []byte + prettyPath string + ) if fs.IsDir() { + prettyPath = filepath.Join(path, "*") + data, err = readFromDir(path, data) if err != nil { return nil, fmt.Errorf("can't read config files: %w", err) } } else { + prettyPath = path + data, err = os.ReadFile(path) if err != nil { return nil, fmt.Errorf("can't read config file: %w", err) } } + cfg.CustomDNS.Zone.configPath = prettyPath + err = unmarshalConfig(logger, data, &cfg) if err != nil { return nil, err diff --git a/config/config_test.go b/config/config_test.go index f5b7ab524..d652b71e2 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -164,6 +164,94 @@ var _ = Describe("Config", func() { defaultTestFileConfig(c) }) }) + When("Test config file contains a zone file with $INCLUDE", func() { + When("The config path is set to the config file", func() { + It("Should support the $INCLUDE directive with a bare filename", func() { + folder := helpertest.NewTmpFolder("zones") + folder.CreateStringFile("other.zone", "www 3600 A 1.2.3.4") + cfgFile := writeConfigYmlWithLocalZoneFile(folder, "other.zone") + + c, err = LoadConfig(cfgFile.Path, true) + + Expect(err).Should(Succeed()) + Expect(c.CustomDNS.Zone.RRs).Should(HaveLen(1)) + + Expect(c.CustomDNS.Zone.RRs["www.example.com."]). + Should(SatisfyAll( + HaveLen(1), + ContainElements( + SatisfyAll( + helpertest.BeDNSRecord("www.example.com.", helpertest.A, "1.2.3.4"), + helpertest.HaveTTL(BeNumerically("==", 3600)), + )), + )) + }) + It("Should support the $INCLUDE directive with a relative filename", func() { + folder := helpertest.NewTmpFolder("zones") + folder.CreateStringFile("other.zone", "www 3600 A 1.2.3.4") + cfgFile := writeConfigYmlWithLocalZoneFile(folder, "./other.zone") + + c, err = LoadConfig(cfgFile.Path, true) + + Expect(err).Should(Succeed()) + Expect(c.CustomDNS.Zone.RRs).Should(HaveLen(1)) + + Expect(c.CustomDNS.Zone.RRs["www.example.com."]). + Should(SatisfyAll( + + HaveLen(1), + ContainElements( + SatisfyAll( + helpertest.BeDNSRecord("www.example.com.", helpertest.A, "1.2.3.4"), + helpertest.HaveTTL(BeNumerically("==", 3600)), + )), + )) + }) + }) + When("The config path is set to a directory", func() { + It("Should support the $INCLUDE directive with a bare filename", func() { + folder := helpertest.NewTmpFolder("zones") + folder.CreateStringFile("other.zone", "www 3600 A 1.2.3.4") + writeConfigYmlWithLocalZoneFile(folder, "other.zone") + + c, err = LoadConfig(folder.Path, true) + + Expect(err).Should(Succeed()) + Expect(c.CustomDNS.Zone.RRs).Should(HaveLen(1)) + + Expect(c.CustomDNS.Zone.RRs["www.example.com."]). + Should(SatisfyAll( + HaveLen(1), + ContainElements( + SatisfyAll( + helpertest.BeDNSRecord("www.example.com.", helpertest.A, "1.2.3.4"), + helpertest.HaveTTL(BeNumerically("==", 3600)), + )), + )) + }) + It("Should support the $INCLUDE directive with a relative filename", func() { + folder := helpertest.NewTmpFolder("zones") + folder.CreateStringFile("other.zone", "www 3600 A 1.2.3.4") + writeConfigYmlWithLocalZoneFile(folder, "./other.zone") + + c, err = LoadConfig(folder.Path, true) + + Expect(err).Should(Succeed()) + Expect(c.CustomDNS.Zone.RRs).Should(HaveLen(1)) + + Expect(c.CustomDNS.Zone.RRs["www.example.com."]). + Should(SatisfyAll( + + HaveLen(1), + ContainElements( + SatisfyAll( + helpertest.BeDNSRecord("www.example.com.", helpertest.A, "1.2.3.4"), + helpertest.HaveTTL(BeNumerically("==", 3600)), + )), + )) + }) + }) + }) When("Test file does not exist", func() { It("should fail", func() { _, err := LoadConfig(tmpDir.JoinPath("config-does-not-exist.yaml"), true) @@ -977,6 +1065,33 @@ func writeConfigYml(tmpDir *helpertest.TmpFolder) *helpertest.TmpFile { ) } +func writeConfigYmlWithLocalZoneFile(tmpDir *helpertest.TmpFolder, includeStr string) *helpertest.TmpFile { + return tmpDir.CreateStringFile("config.yml", + "upstreams:", + " userAgent: testBlocky", + " init:", + " strategy: failOnError", + " groups:", + " default:", + " - tcp+udp:8.8.8.8", + " - tcp+udp:8.8.4.4", + " - 1.1.1.1", + "customDNS:", + " zone: |", + " $ORIGIN example.com.", + " $INCLUDE "+includeStr, + "filtering:", + " queryTypes:", + " - AAAA", + " - A", + "fqdnOnly:", + " enable: true", + "port: 55553,:55554,[::1]:55555", + "logLevel: debug", + "minTlsServeVersion: 1.3", + ) +} + func writeConfigDir(tmpDir *helpertest.TmpFolder) { tmpDir.CreateStringFile("config1.yaml", "upstreams:", diff --git a/config/custom_dns.go b/config/custom_dns.go index 23e0183e2..f9c8df503 100644 --- a/config/custom_dns.go +++ b/config/custom_dns.go @@ -14,14 +14,57 @@ type CustomDNS struct { RewriterConfig `yaml:",inline"` CustomTTL Duration `yaml:"customTTL" default:"1h"` Mapping CustomDNSMapping `yaml:"mapping"` + Zone ZoneFileDNS `yaml:"zone" default:""` FilterUnmappedTypes bool `yaml:"filterUnmappedTypes" default:"true"` } type ( CustomDNSMapping map[string]CustomDNSEntries CustomDNSEntries []dns.RR + + ZoneFileDNS struct { + RRs CustomDNSMapping + configPath string + } ) +func (z *ZoneFileDNS) UnmarshalYAML(unmarshal func(interface{}) error) error { + var input string + if err := unmarshal(&input); err != nil { + return err + } + + result := make(CustomDNSMapping) + + zoneParser := dns.NewZoneParser(strings.NewReader(input), "", z.configPath) + zoneParser.SetIncludeAllowed(true) + + for { + zoneRR, ok := zoneParser.Next() + + if !ok { + if zoneParser.Err() != nil { + return zoneParser.Err() + } + + // Done + break + } + + domain := zoneRR.Header().Name + + if _, ok := result[domain]; !ok { + result[domain] = make(CustomDNSEntries, 0, 1) + } + + result[domain] = append(result[domain], zoneRR) + } + + z.RRs = result + + return nil +} + func (c *CustomDNSEntries) UnmarshalYAML(unmarshal func(interface{}) error) error { var input string if err := unmarshal(&input); err != nil { @@ -30,7 +73,6 @@ func (c *CustomDNSEntries) UnmarshalYAML(unmarshal func(interface{}) error) erro parts := strings.Split(input, ",") result := make(CustomDNSEntries, len(parts)) - containsCNAME := false for i, part := range parts { rr, err := configToRR(part) @@ -38,16 +80,9 @@ func (c *CustomDNSEntries) UnmarshalYAML(unmarshal func(interface{}) error) erro return err } - _, isCNAME := rr.(*dns.CNAME) - containsCNAME = containsCNAME || isCNAME - result[i] = rr } - if containsCNAME && len(result) > 1 { - return fmt.Errorf("when a CNAME record is present, it must be the only record in the mapping") - } - *c = result return nil @@ -70,47 +105,21 @@ func (c *CustomDNS) LogConfig(logger *logrus.Entry) { } } -func removePrefixSuffix(in, prefix string) string { - in = strings.TrimPrefix(in, fmt.Sprintf("%s(", prefix)) - in = strings.TrimSuffix(in, ")") - - return strings.TrimSpace(in) -} - -func configToRR(part string) (dns.RR, error) { - if strings.HasPrefix(part, "CNAME(") { - domain := removePrefixSuffix(part, "CNAME") - domain = dns.Fqdn(domain) - cname := &dns.CNAME{Target: domain} - - return cname, nil +func configToRR(ipStr string) (dns.RR, error) { + ip := net.ParseIP(ipStr) + if ip == nil { + return nil, fmt.Errorf("invalid IP address '%s'", ipStr) } - // Fall back to A/AAAA records to maintain backwards compatibility in config.yml - // We will still remove the A() or AAAA() if it exists - if strings.Contains(part, ".") { // IPV4 address - ipStr := removePrefixSuffix(part, "A") - ip := net.ParseIP(ipStr) - - if ip == nil { - return nil, fmt.Errorf("invalid IP address '%s'", part) - } - + if ip.To4() != nil { a := new(dns.A) a.A = ip return a, nil - } else { // IPV6 address - ipStr := removePrefixSuffix(part, "AAAA") - ip := net.ParseIP(ipStr) - - if ip == nil { - return nil, fmt.Errorf("invalid IP address '%s'", part) - } + } - aaaa := new(dns.AAAA) - aaaa.AAAA = ip + aaaa := new(dns.AAAA) + aaaa.AAAA = ip - return aaaa, nil - } + return aaaa, nil } diff --git a/config/custom_dns_test.go b/config/custom_dns_test.go index d92b2e2ca..37efd91e2 100644 --- a/config/custom_dns_test.go +++ b/config/custom_dns_test.go @@ -2,8 +2,11 @@ package config import ( "errors" + "fmt" "net" + "strings" + . "github.com/0xERR0R/blocky/helpertest" "github.com/creasty/defaults" "github.com/miekg/dns" . "github.com/onsi/ginkgo/v2" @@ -25,7 +28,6 @@ var _ = Describe("CustomDNSConfig", func() { &dns.A{A: net.ParseIP("192.168.143.125")}, &dns.AAAA{AAAA: net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")}, }, - "cname.domain": {&dns.CNAME{Target: "custom.domain"}}, }, } }) @@ -62,12 +64,11 @@ var _ = Describe("CustomDNSConfig", func() { ContainSubstring("custom.domain = "), ContainSubstring("ip6.domain = "), ContainSubstring("multiple.ips = "), - ContainSubstring("cname.domain = "), )) }) }) - Describe("UnmarshalYAML", func() { + Describe("CustomDNSEntries UnmarshalYAML", func() { It("Should parse config as map", func() { c := CustomDNSEntries{} err := c.UnmarshalYAML(func(i interface{}) error { @@ -82,24 +83,125 @@ var _ = Describe("CustomDNSConfig", func() { Expect(aRecord.A).Should(Equal(net.ParseIP("1.2.3.4"))) }) - It("Should return an error if a CNAME is accomanied by any other record", func() { - c := CustomDNSEntries{} + It("should fail if wrong YAML format", func() { + c := &CustomDNSEntries{} err := c.UnmarshalYAML(func(i interface{}) error { - *i.(*string) = "CNAME(example.com),A(1.2.3.4)" + return errors.New("some err") + }) + Expect(err).Should(HaveOccurred()) + Expect(err).Should(MatchError("some err")) + }) + }) + + Describe("ZoneFileDNS UnmarshalYAML", func() { + It("Should parse config as map", func() { + z := ZoneFileDNS{} + err := z.UnmarshalYAML(func(i interface{}) error { + *i.(*string) = strings.TrimSpace(` +$ORIGIN example.com. +www 3600 A 1.2.3.4 +www 3600 AAAA 2001:0db8:85a3:0000:0000:8a2e:0370:7334 +www6 3600 AAAA 2001:0db8:85a3:0000:0000:8a2e:0370:7334 +cname 3600 CNAME www + `) + + return nil + }) + Expect(err).Should(Succeed()) + Expect(z.RRs).Should(HaveLen(3)) + + Expect(z.RRs["www.example.com."]). + Should(SatisfyAll( + HaveLen(2), + ContainElements( + SatisfyAll( + BeDNSRecord("www.example.com.", A, "1.2.3.4"), + HaveTTL(BeNumerically("==", 3600)), + ), + SatisfyAll( + BeDNSRecord("www.example.com.", AAAA, "2001:db8:85a3::8a2e:370:7334"), + HaveTTL(BeNumerically("==", 3600)), + )))) + + Expect(z.RRs["www6.example.com."]). + Should(SatisfyAll( + HaveLen(1), + ContainElements( + SatisfyAll( + BeDNSRecord("www6.example.com.", AAAA, "2001:db8:85a3::8a2e:370:7334"), + HaveTTL(BeNumerically("==", 3600)), + )))) + + Expect(z.RRs["cname.example.com."]). + Should(SatisfyAll( + HaveLen(1), + ContainElements( + SatisfyAll( + BeDNSRecord("cname.example.com.", CNAME, "www.example.com."), + HaveTTL(BeNumerically("==", 3600)), + )))) + }) + + It("Should support the $INCLUDE directive with an absolute path", func() { + folder := NewTmpFolder("zones") + file := folder.CreateStringFile("other.zone", "www 3600 A 1.2.3.4") + + z := ZoneFileDNS{} + err := z.UnmarshalYAML(func(i interface{}) error { + *i.(*string) = strings.TrimSpace(` +$ORIGIN example.com. +$INCLUDE ` + file.Path) + + return nil + }) + Expect(err).Should(Succeed()) + Expect(z.RRs).Should(HaveLen(1)) + + Expect(z.RRs["www.example.com."]). + Should(SatisfyAll( + + HaveLen(1), + ContainElements( + SatisfyAll( + BeDNSRecord("www.example.com.", A, "1.2.3.4"), + HaveTTL(BeNumerically("==", 3600)), + )), + )) + }) + + It("Should return an error if the zone file is malformed", func() { + z := ZoneFileDNS{} + err := z.UnmarshalYAML(func(i interface{}) error { + *i.(*string) = strings.TrimSpace(` +$ORIGIN example.com. +www A 1.2.3.4 + `) return nil }) Expect(err).Should(HaveOccurred()) - Expect(err).Should(MatchError("when a CNAME record is present, it must be the only record in the mapping")) + Expect(err.Error()).Should(ContainSubstring("dns: missing TTL with no previous value")) }) + It("Should return an error if a relative record is provided without an origin", func() { + z := ZoneFileDNS{} + err := z.UnmarshalYAML(func(i interface{}) error { + *i.(*string) = strings.TrimSpace(` +$TTL 3600 +www A 1.2.3.4 + `) - It("should fail if wrong YAML format", func() { - c := &CustomDNSEntries{} - err := c.UnmarshalYAML(func(i interface{}) error { - return errors.New("some err") + return nil }) Expect(err).Should(HaveOccurred()) - Expect(err).Should(MatchError("some err")) + Expect(err.Error()).Should(ContainSubstring("dns: bad owner name: \"www\"")) + }) + It("Should return an error if the unmarshall function returns an error", func() { + z := ZoneFileDNS{} + err := z.UnmarshalYAML(func(i interface{}) error { + return fmt.Errorf("Failed to unmarshal") + }) + Expect(err).Should(HaveOccurred()) + Expect(err).Should(MatchError("Failed to unmarshal")) }) }) }) diff --git a/docs/config.yml b/docs/config.yml index 4d90226fb..8b583d488 100644 --- a/docs/config.yml +++ b/docs/config.yml @@ -47,7 +47,6 @@ customDNS: example.com: printer.lan mapping: printer.lan: 192.168.178.3,2001:0db8:85a3:08d3:1319:8a2e:0370:7344 - second-printer-address.lan: CNAME(printer.lan) # optional: definition, which DNS resolver(s) should be used for queries to the domain (with all sub-domains). Multiple resolvers must be separated by a comma # Example: Query client.fritz.box will ask DNS server 192.168.178.1. This is necessary for local network, to resolve clients by host name diff --git a/docs/configuration.md b/docs/configuration.md index 705652f36..a84b35939 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -259,12 +259,13 @@ You can define your own domain name to IP mappings. For example, you can use a u or define a domain name for your local device on order to use the HTTPS certificate. Multiple IP addresses for one domain must be separated by a comma. -| Parameter | Type | Mandatory | Default value | -| ------------------- | ------------------------------------------- | --------- | ------------- | -| customTTL | duration (no unit is minutes) | no | 1h | -| rewrite | string: string (domain: domain) | no | | -| mapping | string: string (hostname: address or CNAME) | no | | -| filterUnmappedTypes | boolean | no | true | +| Parameter | Type | Mandatory | Default value | +| ------------------- | ------------------------------------------------------ | --------- | ------------- | +| customTTL | duration used for simple mappings (no unit is minutes) | no | 1h | +| rewrite | string: string (domain: domain) | no | | +| mapping | string: string (hostname: address or CNAME) | no | | +| zone | string containing a DNS Zone | no | | +| filterUnmappedTypes | boolean | no | true | !!! example @@ -278,13 +279,22 @@ domain must be separated by a comma. mapping: printer.lan: 192.168.178.3 otherdevice.lan: 192.168.178.15,2001:0db8:85a3:08d3:1319:8a2e:0370:7344 - anothername.lan: CNAME(otherdevice.lan) + zone: | + $ORIGIN example.com. + www 3600 A 1.2.3.4 + @ 3600 CNAME www ``` This configuration will also resolve any subdomain of the defined domain, recursively. For example querying any of `printer.lan`, `my.printer.lan` or `i.love.my.printer.lan` will return 192.168.178.3. -CNAME records are supported by setting the value of the mapping to `CNAME(target)`. Note that the target will be recursively resolved and will return an error if a loop is detected. +CNAME records are supported by utilizing the `zone` parameter. The zone file is a multiline string containing a [DNS Zone File](https://en.wikipedia.org/wiki/Zone_file#Example_file). +For records defined using the `zone` parameter, the `customTTL` parameter is unused. Instead, the TTL is defined in the zone directly. +The following directives are supported in the zone file: +* `$ORIGIN` - sets the origin for relative domain names +* `$TTL` - sets the default TTL for records in the zone +* `$INCLUDE` - includes another zone file relative to the blocky executable +* `$GENERATE` - generates a range of records With the optional parameter `rewrite` you can replace domain part of the query with the defined part **before** the resolver lookup is performed. diff --git a/resolver/custom_dns_resolver.go b/resolver/custom_dns_resolver.go index d96ceb646..602a24fd5 100644 --- a/resolver/custom_dns_resolver.go +++ b/resolver/custom_dns_resolver.go @@ -31,12 +31,25 @@ type CustomDNSResolver struct { // NewCustomDNSResolver creates new resolver instance func NewCustomDNSResolver(cfg config.CustomDNS) *CustomDNSResolver { - m := make(config.CustomDNSMapping, len(cfg.Mapping)) - reverse := make(map[string][]string, len(cfg.Mapping)) + dnsRecords := make(config.CustomDNSMapping, len(cfg.Mapping)+len(cfg.Zone.RRs)) for url, entries := range cfg.Mapping { - m[strings.ToLower(url)] = entries + url = util.ExtractDomainOnly(url) + dnsRecords[url] = entries + for _, entry := range entries { + entry.Header().Ttl = cfg.CustomTTL.SecondsU32() + } + } + + for url, entries := range cfg.Zone.RRs { + url = util.ExtractDomainOnly(url) + dnsRecords[url] = entries + } + + reverse := make(map[string][]string, len(dnsRecords)) + + for url, entries := range dnsRecords { for _, entry := range entries { a, isA := entry.(*dns.A) @@ -59,7 +72,7 @@ func NewCustomDNSResolver(cfg config.CustomDNS) *CustomDNSResolver { typed: withType("custom_dns"), createAnswerFromQuestion: util.CreateAnswerFromQuestion, - mapping: m, + mapping: dnsRecords, reverseAddresses: reverse, } } @@ -175,11 +188,11 @@ func (r *CustomDNSResolver) processDNSEntry( ) ([]dns.RR, error) { switch v := entry.(type) { case *dns.A: - return r.processIP(v.A, question) + return r.processIP(v.A, question, v.Header().Ttl) case *dns.AAAA: - return r.processIP(v.AAAA, question) + return r.processIP(v.AAAA, question, v.Header().Ttl) case *dns.CNAME: - return r.processCNAME(ctx, request, *v, resolvedCnames, question) + return r.processCNAME(ctx, request, *v, resolvedCnames, question, v.Header().Ttl) } return nil, fmt.Errorf("unsupported customDNS RR type %T", entry) @@ -200,11 +213,11 @@ func (r *CustomDNSResolver) Resolve(ctx context.Context, request *model.Request) return resp, nil } -func (r *CustomDNSResolver) processIP(ip net.IP, question dns.Question) (result []dns.RR, err error) { +func (r *CustomDNSResolver) processIP(ip net.IP, question dns.Question, ttl uint32) (result []dns.RR, err error) { result = make([]dns.RR, 0) if isSupportedType(ip, question) { - rr, err := r.createAnswerFromQuestion(question, ip, r.cfg.CustomTTL.SecondsU32()) + rr, err := r.createAnswerFromQuestion(question, ip, ttl) if err != nil { return nil, err } @@ -221,9 +234,9 @@ func (r *CustomDNSResolver) processCNAME( targetCname dns.CNAME, resolvedCnames []string, question dns.Question, + ttl uint32, ) (result []dns.RR, err error) { cname := new(dns.CNAME) - ttl := r.cfg.CustomTTL.SecondsU32() cname.Hdr = dns.RR_Header{Class: dns.ClassINET, Ttl: ttl, Rrtype: dns.TypeCNAME, Name: question.Name} cname.Target = dns.Fqdn(targetCname.Target) result = append(result, cname) diff --git a/resolver/custom_dns_resolver_test.go b/resolver/custom_dns_resolver_test.go index 6cb1a308d..6a4abefbd 100644 --- a/resolver/custom_dns_resolver_test.go +++ b/resolver/custom_dns_resolver_test.go @@ -18,7 +18,8 @@ import ( var _ = Describe("CustomDNSResolver", func() { var ( - TTL = uint32(time.Now().Second()) + TTL = uint32(time.Now().Second()) + zoneTTL = uint32(time.Now().Second() * 2) sut *CustomDNSResolver m *mockResolver @@ -38,6 +39,8 @@ var _ = Describe("CustomDNSResolver", func() { ctx, cancelFn = context.WithCancel(context.Background()) DeferCleanup(cancelFn) + zoneHdr := dns.RR_Header{Ttl: zoneTTL} + cfg = config.CustomDNS{ Mapping: config.CustomDNSMapping{ "custom.domain": {&dns.A{A: net.ParseIP("192.168.143.123")}}, @@ -47,11 +50,16 @@ var _ = Describe("CustomDNSResolver", func() { &dns.A{A: net.ParseIP("192.168.143.125")}, &dns.AAAA{AAAA: net.ParseIP("2001:0db8:85a3:0000:0000:8a2e:0370:7334")}, }, - "cname.domain": {&dns.CNAME{Target: "custom.domain"}}, - "cname.ip6": {&dns.CNAME{Target: "ip6.domain"}}, - "cname.example": {&dns.CNAME{Target: "example.com"}}, - "cname.recursive": {&dns.CNAME{Target: "cname.recursive"}}, - "mx.domain": {&dns.MX{Mx: "mx.domain"}}, + }, + Zone: config.ZoneFileDNS{ + RRs: config.CustomDNSMapping{ + "example.zone.": {&dns.A{A: net.ParseIP("1.2.3.4"), Hdr: zoneHdr}}, + "cname.domain.": {&dns.CNAME{Target: "custom.domain", Hdr: zoneHdr}}, + "cname.ip6.": {&dns.CNAME{Target: "ip6.domain", Hdr: zoneHdr}}, + "cname.example.": {&dns.CNAME{Target: "example.com", Hdr: zoneHdr}}, + "cname.recursive.": {&dns.CNAME{Target: "cname.recursive", Hdr: zoneHdr}}, + "mx.domain.": {&dns.MX{Mx: "mx.domain", Hdr: zoneHdr}}, + }, }, CustomTTL: config.Duration(time.Duration(TTL) * time.Second), FilterUnmappedTypes: true, @@ -136,6 +144,19 @@ var _ = Describe("CustomDNSResolver", func() { When("Ip 4 mapping is defined for custom domain and", func() { Context("filterUnmappedTypes is true", func() { BeforeEach(func() { cfg.FilterUnmappedTypes = true }) + It("defined ip4 query should be resolved from zone mappings and should use the TTL defined in the zone", func() { + Expect(sut.Resolve(ctx, newRequest("example.zone.", A))). + Should( + SatisfyAll( + BeDNSRecord("example.zone.", A, "1.2.3.4"), + HaveTTL(BeNumerically("==", zoneTTL)), + HaveResponseType(ResponseTypeCUSTOMDNS), + HaveReason("CUSTOM DNS"), + HaveReturnCode(dns.RcodeSuccess), + )) + // will not delegate to next resolver + m.AssertNotCalled(GinkgoT(), "Resolve", mock.Anything) + }) It("defined ip4 query should be resolved", func() { Expect(sut.Resolve(ctx, newRequest("custom.domain.", A))). Should(