diff --git a/Gopkg.lock b/Gopkg.lock index 2ba57475a..9a382a7b0 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -330,6 +330,15 @@ packages = ["codec"] revision = "f3cacc17c85ecb7f1b6a9e373ee85d1480919868" +[[projects]] + branch = "master" + name = "github.robot.car/cruise/coredns" + packages = [ + "plugin/file", + "plugin/pkg/log" + ] + revision = "18a77cd04557b810eba96a7239d39ee2d7a92157" + [[projects]] branch = "master" name = "golang.org/x/crypto" @@ -582,6 +591,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "851e08825d02558de62afea288af58e89bc67fe93a534c5e81f487e35328df22" + inputs-digest = "9c6559abfab43b450cf71b6cb95c0ba93fac74999a8cdc341ea725e82c29cebf" solver-name = "gps-cdcl" solver-version = 1 diff --git a/plugin/route53/README.md b/plugin/route53/README.md index 2044ad8d7..f62ea42e6 100644 --- a/plugin/route53/README.md +++ b/plugin/route53/README.md @@ -7,13 +7,15 @@ ## Description The route53 plugin is useful for serving zones from resource record sets in AWS route53. This plugin -only supports A and AAAA records. The route53 plugin can be used when coredns is deployed on AWS. +supports all Amazon Route 53 records (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/ResourceRecordTypes.html). +The route53 plugin can be used when coredns is deployed on AWS or elsewhere. ## Syntax ~~~ txt route53 [ZONE:HOSTED_ZONE_ID...] { [aws_access_key AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY] + upstream [ADDRESS...] } ~~~ @@ -23,18 +25,23 @@ route53 [ZONE:HOSTED_ZONE_ID...] { to be used when query AWS (optional). If they are not provided, then coredns tries to access AWS credentials the same way as AWS CLI, e.g., environmental variables, AWS credentials file, instance profile credentials, etc. +* `upstream` [**ADDRESS**...] specifies upstream resolver(s) used for resolving services that point + to external hosts (eg. used to resolve CNAMEs). If no **ADDRESS** is given, CoreDNS will resolve + against itself. **ADDRESS** can be an IP, an IP:port or a path to a file structured like + resolv.conf (**NB**: Currently a bug (#2099) is preventing the use of self-resolver). ## Examples -Enable route53, with implicit aws credentials: +Enable route53 with implicit aws credentials and an upstream: ~~~ txt . { route53 example.org.:Z1Z2Z3Z4DZ5Z6Z7 + upstream 10.0.0.1 } ~~~ -Enable route53, with explicit aws credentials: +Enable route53 with explicit aws credentials: ~~~ txt . { diff --git a/plugin/route53/route53.go b/plugin/route53/route53.go index 9629e2787..58d4335c4 100644 --- a/plugin/route53/route53.go +++ b/plugin/route53/route53.go @@ -1,12 +1,16 @@ // Package route53 implements a plugin that returns resource records -// from AWS route53 +// from AWS route53. package route53 import ( "context" - "net" + "fmt" + "sync" + "time" "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/file" + "github.com/coredns/coredns/plugin/pkg/upstream" "github.com/coredns/coredns/request" "github.com/aws/aws-sdk-go/aws" @@ -15,96 +19,173 @@ import ( "github.com/miekg/dns" ) -// Route53 is a plugin that returns RR from AWS route53 +// Route53 is a plugin that returns RR from AWS route53. type Route53 struct { Next plugin.Handler - zones []string - keys map[string]string - client route53iface.Route53API + zoneNames []string + client route53iface.Route53API + upstream *upstream.Upstream + + zMu sync.RWMutex + zones map[string]*zone } -// ServeDNS implements the plugin.Handler interface. -func (rr Route53) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { +type zone struct { + id string + z *file.Zone +} + +// New returns new *Route53. +func New(ctx context.Context, c route53iface.Route53API, keys map[string]string, up *upstream.Upstream) (*Route53, error) { + zones := make(map[string]*zone, len(keys)) + zoneNames := make([]string, 0, len(keys)) + for dns, id := range keys { + _, err := c.ListHostedZonesByNameWithContext(ctx, &route53.ListHostedZonesByNameInput{ + DNSName: aws.String(dns), + HostedZoneId: aws.String(id), + }) + if err != nil { + return nil, err + } + zones[dns] = &zone{id: id, z: file.NewZone(dns, "")} + zoneNames = append(zoneNames, dns) + } + return &Route53{ + client: c, + zoneNames: zoneNames, + zones: zones, + upstream: up, + }, nil +} + +// Run executes first update, spins up an update forever-loop. +// Returns error if first update fails. +func (h *Route53) Run(ctx context.Context) error { + if err := h.updateZones(ctx); err != nil { + return err + } + go func() { + for { + select { + case <-ctx.Done(): + log.Infof("Breaking out of Route53 update loop: %v", ctx.Err()) + return + case <-time.After(1 * time.Minute): + if err := h.updateZones(ctx); err != nil && ctx.Err() == nil /* Don't log error if ctx expired. */ { + log.Errorf("Failed to update zones: %v", err) + } + } + } + }() + return nil +} + +// ServeDNS implements the plugin.Handler.ServeDNS. +func (h *Route53) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { state := request.Request{W: w, Req: r} qname := state.Name() - zone := plugin.Zones(rr.zones).Matches(qname) - if zone == "" { - return plugin.NextOrFailure(rr.Name(), rr.Next, ctx, w, r) + zName := plugin.Zones(h.zoneNames).Matches(qname) + if zName == "" { + return plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) } - - output, err := rr.client.ListResourceRecordSets(&route53.ListResourceRecordSetsInput{ - HostedZoneId: aws.String(rr.keys[zone]), - StartRecordName: aws.String(qname), - StartRecordType: aws.String(state.Type()), - MaxItems: aws.String("1"), - }) - if err != nil { - return dns.RcodeServerFailure, err - } - - answers := []dns.RR{} - switch state.QType() { - case dns.TypeA: - answers = a(qname, output.ResourceRecordSets) - case dns.TypeAAAA: - answers = aaaa(qname, output.ResourceRecordSets) - case dns.TypePTR: - answers = ptr(qname, output.ResourceRecordSets) - } - - if len(answers) == 0 { - return plugin.NextOrFailure(rr.Name(), rr.Next, ctx, w, r) + z, ok := h.zones[zName] + if !ok || z == nil { + return dns.RcodeServerFailure, nil } m := new(dns.Msg) m.SetReply(r) m.Authoritative, m.RecursionAvailable = true, true - m.Answer = answers + var result file.Result + h.zMu.RLock() + m.Answer, m.Ns, m.Extra, result = z.z.Lookup(state, qname) + h.zMu.RUnlock() + + switch result { + case file.Success: + case file.NoData: + case file.NameError: + m.Rcode = dns.RcodeNameError + case file.Delegation: + m.Authoritative = false + case file.ServerFailure: + return dns.RcodeServerFailure, nil + } w.WriteMsg(m) return dns.RcodeSuccess, nil } -func a(zone string, rrss []*route53.ResourceRecordSet) []dns.RR { - answers := []dns.RR{} - for _, rrs := range rrss { - for _, rr := range rrs.ResourceRecords { - r := new(dns.A) - r.Hdr = dns.RR_Header{Name: zone, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: uint32(aws.Int64Value(rrs.TTL))} - r.A = net.ParseIP(aws.StringValue(rr.Value)).To4() - answers = append(answers, r) +func updateZoneFromRRS(rrs *route53.ResourceRecordSet, z *file.Zone) error { + for _, rr := range rrs.ResourceRecords { + // Assemble RFC 1035 conforming record to pass into dns scanner. + rfc1035 := fmt.Sprintf("%s %d IN %s %s", aws.StringValue(rrs.Name), aws.Int64Value(rrs.TTL), aws.StringValue(rrs.Type), aws.StringValue(rr.Value)) + r, err := dns.NewRR(rfc1035) + if err != nil { + return fmt.Errorf("failed to parse resource record: %v", err) } + + z.Insert(r) } - return answers + return nil } -func aaaa(zone string, rrss []*route53.ResourceRecordSet) []dns.RR { - answers := []dns.RR{} - for _, rrs := range rrss { - for _, rr := range rrs.ResourceRecords { - r := new(dns.AAAA) - r.Hdr = dns.RR_Header{Name: zone, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: uint32(aws.Int64Value(rrs.TTL))} - r.AAAA = net.ParseIP(aws.StringValue(rr.Value)).To16() - answers = append(answers, r) +// updateZones re-queries resource record sets for each zone and updates the +// zone object. +// Returns error if any zones error'ed out, but waits for other zones to +// complete first. +func (h *Route53) updateZones(ctx context.Context) error { + errc := make(chan error) + defer close(errc) + for zName, z := range h.zones { + go func(zName string) { + var err error + defer func() { + errc <- err + }() + + newZ := file.NewZone(zName, "") + newZ.Upstream = *h.upstream + + in := &route53.ListResourceRecordSetsInput{ + HostedZoneId: aws.String(z.id), + } + err = h.client.ListResourceRecordSetsPagesWithContext(ctx, in, + func(out *route53.ListResourceRecordSetsOutput, last bool) bool { + for _, rrs := range out.ResourceRecordSets { + if err := updateZoneFromRRS(rrs, newZ); err != nil { + // Maybe unsupported record type. Log and carry on. + log.Warningf("Failed to process resource record set: %v", err) + } + } + return true + }) + if err != nil { + err = fmt.Errorf("failed to list resource records for %v:%v from route53: %v", zName, z.id, err) + return + } + + h.zMu.Lock() + z.z = newZ + h.zMu.Unlock() + }(zName) + } + // Collect errors (if any). This will also sync on all zones updates + // completion. + var errs []string + for i := 0; i < len(h.zones); i++ { + err := <-errc + if err != nil { + errs = append(errs, err.Error()) } } - return answers -} - -func ptr(zone string, rrss []*route53.ResourceRecordSet) []dns.RR { - answers := []dns.RR{} - for _, rrs := range rrss { - for _, rr := range rrs.ResourceRecords { - r := new(dns.PTR) - r.Hdr = dns.RR_Header{Name: zone, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: uint32(aws.Int64Value(rrs.TTL))} - r.Ptr = aws.StringValue(rr.Value) - answers = append(answers, r) - } + if len(errs) != 0 { + return fmt.Errorf("errors updating zones: %v", errs) } - return answers + return nil } -// Name implements the Handler interface. -func (rr Route53) Name() string { return "route53" } +// Name implements plugin.Handler.Name. +func (h *Route53) Name() string { return "route53" } diff --git a/plugin/route53/route53_test.go b/plugin/route53/route53_test.go index 60ddefcfd..519b211a4 100644 --- a/plugin/route53/route53_test.go +++ b/plugin/route53/route53_test.go @@ -2,84 +2,163 @@ package route53 import ( "context" + "errors" + "reflect" "testing" "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/pkg/upstream" "github.com/coredns/coredns/plugin/test" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/service/route53" "github.com/aws/aws-sdk-go/service/route53/route53iface" "github.com/miekg/dns" ) -type mockedRoute53 struct { +type fakeRoute53 struct { route53iface.Route53API } -func (mockedRoute53) ListResourceRecordSets(input *route53.ListResourceRecordSetsInput) (*route53.ListResourceRecordSetsOutput, error) { - var value string - switch aws.StringValue(input.StartRecordType) { - case "A": - value = "10.2.3.4" - case "AAAA": - value = "2001:db8:85a3::8a2e:370:7334" - case "PTR": - value = "ptr.example.org" +func (fakeRoute53) ListHostedZonesByNameWithContext(_ aws.Context, input *route53.ListHostedZonesByNameInput, _ ...request.Option) (*route53.ListHostedZonesByNameOutput, error) { + return nil, nil +} + +func (fakeRoute53) ListResourceRecordSetsPagesWithContext(_ aws.Context, in *route53.ListResourceRecordSetsInput, fn func(*route53.ListResourceRecordSetsOutput, bool) bool, _ ...request.Option) error { + if aws.StringValue(in.HostedZoneId) == "0987654321" { + return errors.New("bad. zone is bad") } - return &route53.ListResourceRecordSetsOutput{ - ResourceRecordSets: []*route53.ResourceRecordSet{ - { - ResourceRecords: []*route53.ResourceRecord{ - { - Value: aws.String(value), - }, + var rrs []*route53.ResourceRecordSet + for _, r := range []struct { + rType, name, value string + }{ + {"A", "example.org.", "1.2.3.4"}, + {"AAAA", "example.org.", "2001:db8:85a3::8a2e:370:7334"}, + {"CNAME", "sample.example.org.", "example.org"}, + {"PTR", "example.org.", "ptr.example.org."}, + {"SOA", "org.", "ns-1536.awsdns-00.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400"}, + {"NS", "com.", "ns-1536.awsdns-00.co.uk."}, + // Unsupported type should be ignored. + {"YOLO", "swag.", "foobar"}, + } { + rrs = append(rrs, &route53.ResourceRecordSet{Type: aws.String(r.rType), + Name: aws.String(r.name), + ResourceRecords: []*route53.ResourceRecord{ + { + Value: aws.String(r.value), }, }, - }, - }, nil + TTL: aws.Int64(300), + }) + } + if ok := fn(&route53.ListResourceRecordSetsOutput{ + ResourceRecordSets: rrs, + }, true); !ok { + return errors.New("paging function return false") + } + return nil } func TestRoute53(t *testing.T) { - r := Route53{ - zones: []string{"example.org."}, - keys: map[string]string{"example.org.": "1234567890"}, - client: mockedRoute53{}, + ctx := context.Background() + + r, err := New(ctx, fakeRoute53{}, map[string]string{"bad.": "0987654321"}, &upstream.Upstream{}) + if err != nil { + t.Fatalf("Failed to create Route53: %v", err) + } + if err = r.Run(ctx); err == nil { + t.Fatalf("Expected errors for zone bad.") + } + + r, err = New(ctx, fakeRoute53{}, map[string]string{"org.": "1234567890"}, &upstream.Upstream{}) + if err != nil { + t.Fatalf("Failed to create Route53: %v", err) + } + r.Next = test.ErrorHandler() + err = r.Run(ctx) + if err != nil { + t.Fatalf("Failed to initialize Route53: %v", err) } tests := []struct { - qname string - qtype uint16 - expectedCode int - expectedReply []string // ownernames for the records in the additional section. - expectedErr error + qname string + qtype uint16 + expectedCode int + wantAnswer []string // ownernames for the records in the additional section. + wantNS []string + expectedErr error }{ + // 0. example.org A found - success. { - qname: "example.org", - qtype: dns.TypeA, - expectedCode: dns.RcodeSuccess, - expectedReply: []string{"10.2.3.4"}, - expectedErr: nil, + qname: "example.org", + qtype: dns.TypeA, + expectedCode: dns.RcodeSuccess, + wantAnswer: []string{"example.org. 300 IN A 1.2.3.4"}, }, + // 1. example.org AAAA found - success. { - qname: "example.org", - qtype: dns.TypeAAAA, - expectedCode: dns.RcodeSuccess, - expectedReply: []string{"2001:db8:85a3::8a2e:370:7334"}, - expectedErr: nil, + qname: "example.org", + qtype: dns.TypeAAAA, + expectedCode: dns.RcodeSuccess, + wantAnswer: []string{"example.org. 300 IN AAAA 2001:db8:85a3::8a2e:370:7334"}, }, + // 2. exampled.org PTR found - success. { - qname: "example.org", - qtype: dns.TypePTR, - expectedCode: dns.RcodeSuccess, - expectedReply: []string{"ptr.example.org"}, - expectedErr: nil, + qname: "example.org", + qtype: dns.TypePTR, + expectedCode: dns.RcodeSuccess, + wantAnswer: []string{"example.org. 300 IN PTR ptr.example.org."}, + }, + // 3. sample.example.org points to example.org CNAME. + // Query must return both CNAME and A recs. + { + qname: "sample.example.org", + qtype: dns.TypeA, + expectedCode: dns.RcodeSuccess, + wantAnswer: []string{ + "sample.example.org. 300 IN CNAME example.org.", + "example.org. 300 IN A 1.2.3.4", + }, + }, + // 4. Explicit CNAME query for sample.example.org. + // Query must return just CNAME. + { + qname: "sample.example.org", + qtype: dns.TypeCNAME, + expectedCode: dns.RcodeSuccess, + wantAnswer: []string{"sample.example.org. 300 IN CNAME example.org."}, + }, + // 5. Explicit SOA query for example.org. + { + qname: "example.org", + qtype: dns.TypeSOA, + expectedCode: dns.RcodeSuccess, + wantAnswer: []string{"org. 300 IN SOA ns-1536.awsdns-00.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400"}, + }, + // 6. Explicit SOA query for example.org. + { + qname: "example.org", + qtype: dns.TypeNS, + expectedCode: dns.RcodeSuccess, + wantNS: []string{"org. 300 IN SOA ns-1536.awsdns-00.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400"}, + }, + // 7. Zone not configured. + { + qname: "badexample.com", + qtype: dns.TypeA, + expectedCode: dns.RcodeServerFailure, + }, + // 8. No record found. Return SOA record. + { + qname: "bad.org", + qtype: dns.TypeA, + expectedCode: dns.RcodeSuccess, + wantNS: []string{"org. 300 IN SOA ns-1536.awsdns-00.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600 86400"}, }, } - ctx := context.TODO() - - for i, tc := range tests { + for ti, tc := range tests { req := new(dns.Msg) req.SetQuestion(dns.Fqdn(tc.qname), tc.qtype) @@ -87,24 +166,32 @@ func TestRoute53(t *testing.T) { code, err := r.ServeDNS(ctx, rec, req) if err != tc.expectedErr { - t.Errorf("Test %d: Expected error %v, but got %v", i, tc.expectedErr, err) + t.Fatalf("Test %d: Expected error %v, but got %v", ti, tc.expectedErr, err) } if code != int(tc.expectedCode) { - t.Errorf("Test %d: Expected status code %d, but got %d", i, tc.expectedCode, code) + t.Fatalf("Test %d: Expected status code %s, but got %s", ti, dns.RcodeToString[tc.expectedCode], dns.RcodeToString[code]) } - if len(tc.expectedReply) != 0 { - for i, expected := range tc.expectedReply { - var actual string - switch tc.qtype { - case dns.TypeA: - actual = rec.Msg.Answer[i].(*dns.A).A.String() - case dns.TypeAAAA: - actual = rec.Msg.Answer[i].(*dns.AAAA).AAAA.String() - case dns.TypePTR: - actual = rec.Msg.Answer[i].(*dns.PTR).Ptr + + if len(tc.wantAnswer) != len(rec.Msg.Answer) { + t.Errorf("Test %d: Unexpected number of Answers. Want: %d, got: %d", ti, len(tc.wantAnswer), len(rec.Msg.Answer)) + } else { + for i, gotAnswer := range rec.Msg.Answer { + if gotAnswer.String() != tc.wantAnswer[i] { + t.Errorf("Test %d: Unexpected answer.\nWant:\n\t%s\nGot:\n\t%s", ti, tc.wantAnswer[i], gotAnswer) } - if actual != expected { - t.Errorf("Test %d: Expected answer %s, but got %s", i, expected, actual) + } + } + + if len(tc.wantNS) != len(rec.Msg.Ns) { + t.Errorf("Test %d: Unexpected NS number. Want: %d, got: %d", ti, len(tc.wantNS), len(rec.Msg.Ns)) + } else { + for i, ns := range rec.Msg.Ns { + got, ok := ns.(*dns.SOA) + if !ok { + t.Errorf("Test %d: Unexpected NS type. Want: SOA, got: %v", ti, reflect.TypeOf(got)) + } + if got.String() != tc.wantNS[i] { + t.Errorf("Test %d: Unexpected NS. Want: %v, got: %v", ti, tc.wantNS[i], got) } } } diff --git a/plugin/route53/setup.go b/plugin/route53/setup.go index 92e25a738..ef55e59b4 100644 --- a/plugin/route53/setup.go +++ b/plugin/route53/setup.go @@ -1,10 +1,13 @@ package route53 import ( + "context" "strings" "github.com/coredns/coredns/core/dnsserver" "github.com/coredns/coredns/plugin" + clog "github.com/coredns/coredns/plugin/pkg/log" + "github.com/coredns/coredns/plugin/pkg/upstream" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" @@ -14,6 +17,8 @@ import ( "github.com/mholt/caddy" ) +var log = clog.NewWithPlugin("route53") + func init() { caddy.RegisterPlugin("route53", caddy.Plugin{ ServerType: "dns", @@ -30,7 +35,8 @@ func init() { func setup(c *caddy.Controller, f func(*credentials.Credentials) route53iface.Route53API) error { keys := map[string]string{} - var credential *credentials.Credentials + credential := credentials.NewEnvCredentials() + up, _ := upstream.New(nil) for c.Next() { args := c.RemainingArgs() @@ -57,32 +63,35 @@ func setup(c *caddy.Controller, f func(*credentials.Credentials) route53iface.Ro return c.Errf("invalid access key '%v'", v) } credential = credentials.NewStaticCredentials(v[0], v[1], "") + case "upstream": + args := c.RemainingArgs() + // TODO(dilyevsky): There is a bug that causes coredns to crash + // when no upstream endpoint is provided. + if len(args) == 0 { + return c.Errf("local upstream not supported. please provide upstream endpoint") + } + var err error + up, err = upstream.New(args) + if err != nil { + return c.Errf("invalid upstream: %v", err) + } default: return c.Errf("unknown property '%s'", c.Val()) } } } client := f(credential) - zones := []string{} - for zone, v := range keys { - // Make sure enough credentials is needed - if _, err := client.ListResourceRecordSets(&route53.ListResourceRecordSetsInput{ - HostedZoneId: aws.String(v), - MaxItems: aws.String("1"), - }); err != nil { - return c.Errf("aws error: '%s'", err) - } - - zones = append(zones, zone) + ctx := context.Background() + h, err := New(ctx, client, keys, &up) + if err != nil { + return c.Errf("failed to create Route53 plugin: %v", err) + } + if err := h.Run(ctx); err != nil { + return c.Errf("failed to initialize Route53 plugin: %v", err) } - dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { - return Route53{ - Next: next, - keys: keys, - zones: zones, - client: client, - } + h.Next = next + return h }) return nil diff --git a/plugin/route53/setup_test.go b/plugin/route53/setup_test.go index 8e90e9965..a5491a935 100644 --- a/plugin/route53/setup_test.go +++ b/plugin/route53/setup_test.go @@ -10,7 +10,7 @@ import ( func TestSetupRoute53(t *testing.T) { f := func(credential *credentials.Credentials) route53iface.Route53API { - return mockedRoute53{} + return fakeRoute53{} } c := caddy.NewTestController("dns", `route53`) @@ -34,4 +34,26 @@ func TestSetupRoute53(t *testing.T) { if err := setup(c, f); err == nil { t.Fatalf("Expected errors, but got: %v", err) } + + c = caddy.NewTestController("dns", `route53 example.org:12345678 { + upstream +}`) + if err := setup(c, f); err == nil { + t.Fatalf("Expected errors, but got: %v", err) + } + + c = caddy.NewTestController("dns", `route53 example.org:12345678 { + wat +}`) + if err := setup(c, f); err == nil { + t.Fatalf("Expected errors, but got: %v", err) + } + + c = caddy.NewTestController("dns", `route53 example.org:12345678 { + aws_access_key ACCESS_KEY_ID SEKRIT_ACCESS_KEY + upstream 1.2.3.4 +}`) + if err := setup(c, f); err != nil { + t.Fatalf("Unexpected errors: %v", err) + } }