From 194b0f95b459a593deb6c2d1e048e020070a841a Mon Sep 17 00:00:00 2001 From: Palash Nigam Date: Sun, 18 Aug 2019 02:29:09 +0530 Subject: [PATCH] Add Google Cloud DNS plugin (#3011) Signed-off-by: Palash Nigam Closes: #2822 --- .vscode/launch.json | 17 ++ core/dnsserver/zdirectives.go | 1 + core/plugin/zplugin.go | 1 + go.mod | 1 + go.sum | 1 + plugin.cfg | 4 + plugin/clouddns/README.md | 67 +++++++ plugin/clouddns/clouddns.go | 222 ++++++++++++++++++++++ plugin/clouddns/clouddns_test.go | 316 +++++++++++++++++++++++++++++++ plugin/clouddns/gcp.go | 32 ++++ plugin/clouddns/log_test.go | 5 + plugin/clouddns/setup.go | 110 +++++++++++ plugin/clouddns/setup_test.go | 48 +++++ 13 files changed, 825 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 plugin/clouddns/README.md create mode 100644 plugin/clouddns/clouddns.go create mode 100644 plugin/clouddns/clouddns_test.go create mode 100644 plugin/clouddns/gcp.go create mode 100644 plugin/clouddns/log_test.go create mode 100644 plugin/clouddns/setup.go create mode 100644 plugin/clouddns/setup_test.go diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..c23774cdd --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${fileDirname}", + "env": {}, + "args": [] + } + ] +} \ No newline at end of file diff --git a/core/dnsserver/zdirectives.go b/core/dnsserver/zdirectives.go index b897cc04f..b48154448 100644 --- a/core/dnsserver/zdirectives.go +++ b/core/dnsserver/zdirectives.go @@ -37,6 +37,7 @@ var Directives = []string{ "hosts", "route53", "azure", + "clouddns", "federation", "k8s_external", "kubernetes", diff --git a/core/plugin/zplugin.go b/core/plugin/zplugin.go index 0c3b447ce..104a3e68a 100644 --- a/core/plugin/zplugin.go +++ b/core/plugin/zplugin.go @@ -13,6 +13,7 @@ import ( _ "github.com/coredns/coredns/plugin/cache" _ "github.com/coredns/coredns/plugin/cancel" _ "github.com/coredns/coredns/plugin/chaos" + _ "github.com/coredns/coredns/plugin/clouddns" _ "github.com/coredns/coredns/plugin/debug" _ "github.com/coredns/coredns/plugin/dnssec" _ "github.com/coredns/coredns/plugin/dnstap" diff --git a/go.mod b/go.mod index b4e452793..99828467b 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( golang.org/x/net v0.0.0-20190628185345-da137c7871d7 // indirect golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 // indirect + google.golang.org/api v0.7.0 google.golang.org/genproto v0.0.0-20190701230453-710ae3a149df // indirect google.golang.org/grpc v1.22.0 gopkg.in/DataDog/dd-trace-go.v1 v1.16.1 diff --git a/go.sum b/go.sum index e8ded047f..860fb298f 100644 --- a/go.sum +++ b/go.sum @@ -145,6 +145,7 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf h1:+RRA9JqSOZFfKrOeqr2z77+8R2RKyh8PG66dcu1V0ck= github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= diff --git a/plugin.cfg b/plugin.cfg index 4d18c7d4e..34d949253 100644 --- a/plugin.cfg +++ b/plugin.cfg @@ -45,7 +45,11 @@ autopath:autopath template:template hosts:hosts route53:route53 +<<<<<<< 6a6e9a9b33731656b51655072951649d9e716613 azure:azure +======= +clouddns:clouddns +>>>>>>> Add Google Cloud DNS plugin federation:federation k8s_external:k8s_external kubernetes:kubernetes diff --git a/plugin/clouddns/README.md b/plugin/clouddns/README.md new file mode 100644 index 000000000..4aa5f04fb --- /dev/null +++ b/plugin/clouddns/README.md @@ -0,0 +1,67 @@ +# clouddns + +## Name + +*clouddns* - enables serving zone data from GCP clouddns. + +## Description + +The clouddns plugin is useful for serving zones from resource record +sets in GCP clouddns. This plugin supports all [Google Cloud DNS records](https://cloud.google.com/dns/docs/overview#supported_dns_record_types). +The clouddns plugin can be used when coredns is deployed on GCP or elsewhere. + +## Syntax + +~~~ txt +clouddns [ZONE:PROJECT_NAME:HOSTED_ZONE_NAME...] { + credentials [FILENAME] + fallthrough [ZONES...] +} +~~~ + +* **ZONE** the name of the domain to be accessed. When there are multiple zones with overlapping + domains (private vs. public hosted zone), CoreDNS does the lookup in the given order here. + Therefore, for a non-existing resource record, SOA response will be from the rightmost zone. + +* **HOSTED_ZONE_NAME** the name of the hosted zone that contains the resource record sets to be + accessed. + +* `credentials` is used for reading the credential file. + +* **FILENAME** GCP credentials file path. + +* `fallthrough` If zone matches and no record can be generated, pass request to the next plugin. + If **[ZONES...]** is omitted, then fallthrough happens for all zones for which the plugin is + authoritative. If specific zones are listed (for example `in-addr.arpa` and `ip6.arpa`), then + only queries for those zones will be subject to fallthrough. + +* **ZONES** zones it should be authoritative for. If empty, the zones from the configuration block + +## Examples + +Enable clouddns with implicit GCP credentials and resolve CNAMEs via 10.0.0.1: + +~~~ txt +. { + clouddns example.org.:gcp-example-project:example-zone + forward . 10.0.0.1 +} +~~~ + +Enable clouddns with fallthrough: + +~~~ txt +. { + clouddns example.org.:gcp-example-project:example-zone clouddns example.com.:gcp-example-project:example-zone-2 { + fallthrough example.gov. + } +} +~~~ + +Enable clouddns with multiple hosted zones with the same domain: + +~~~ txt +. { + clouddns example.org.:gcp-example-project:example-zone example.com.:gcp-example-project:other-example-zone +} +~~~ diff --git a/plugin/clouddns/clouddns.go b/plugin/clouddns/clouddns.go new file mode 100644 index 000000000..ab04e5f75 --- /dev/null +++ b/plugin/clouddns/clouddns.go @@ -0,0 +1,222 @@ +// Package clouddns implements a plugin that returns resource records +// from GCP Cloud DNS. +package clouddns + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/file" + "github.com/coredns/coredns/plugin/pkg/fall" + "github.com/coredns/coredns/plugin/pkg/upstream" + "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + gcp "google.golang.org/api/dns/v1" +) + +// CloudDNS is a plugin that returns RR from GCP Cloud DNS. +type CloudDNS struct { + Next plugin.Handler + Fall fall.F + + zoneNames []string + client gcpDNS + upstream *upstream.Upstream + + zMu sync.RWMutex + zones zones +} + +type zone struct { + projectName string + zoneName string + z *file.Zone + dns string +} + +type zones map[string][]*zone + +// New reads from the keys map which uses domain names as its key and a colon separated +// string of project name and hosted zone name lists as its values, validates +// that each domain name/zone id pair does exist, and returns a new *CloudDNS. +// In addition to this, upstream is passed for doing recursive queries against CNAMEs. +// Returns error if it cannot verify any given domain name/zone id pair. +func New(ctx context.Context, c gcpDNS, keys map[string][]string, up *upstream.Upstream) (*CloudDNS, error) { + zones := make(map[string][]*zone, len(keys)) + zoneNames := make([]string, 0, len(keys)) + for dnsName, hostedZoneDetails := range keys { + for _, hostedZone := range hostedZoneDetails { + ss := strings.SplitN(hostedZone, ":", 2) + if len(ss) != 2 { + return nil, errors.New("either project or zone name missing") + } + err := c.zoneExists(ss[0], ss[1]) + if err != nil { + return nil, err + } + fqdnDNSName := dns.Fqdn(dnsName) + if _, ok := zones[fqdnDNSName]; !ok { + zoneNames = append(zoneNames, fqdnDNSName) + } + zones[fqdnDNSName] = append(zones[fqdnDNSName], &zone{projectName: ss[0], zoneName: ss[1], dns: fqdnDNSName, z: file.NewZone(fqdnDNSName, "")}) + } + } + return &CloudDNS{ + 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 *CloudDNS) 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 CloudDNS 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 interface. +func (h *CloudDNS) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := request.Request{W: w, Req: r} + qname := state.Name() + + zName := plugin.Zones(h.zoneNames).Matches(qname) + if zName == "" { + return plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) + } + + z, ok := h.zones[zName] // ok true if we are authoritive for the zone + if !ok || z == nil { + return dns.RcodeServerFailure, nil + } + + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative = true + var result file.Result + + for _, hostedZone := range z { + h.zMu.RLock() + m.Answer, m.Ns, m.Extra, result = hostedZone.z.Lookup(ctx, state, qname) + h.zMu.RUnlock() + + // Take the answer if it's non-empty OR if there is another + // record type exists for this name (NODATA). + if len(m.Answer) != 0 || result == file.NoData { + break + } + } + + if len(m.Answer) == 0 && result != file.NoData && h.Fall.Through(qname) { + return plugin.NextOrFailure(h.Name(), h.Next, ctx, w, r) + } + + 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 updateZoneFromRRS(rrs *gcp.ResourceRecordSetsListResponse, z *file.Zone) error { + for _, rr := range rrs.Rrsets { + var rfc1035 string + var r dns.RR + var err error + for _, value := range rr.Rrdatas { + if rr.Type == "CNAME" || rr.Type == "PTR" { + value = dns.Fqdn(value) + } + + // Assemble RFC 1035 conforming record to pass into dns scanner. + rfc1035 = fmt.Sprintf("%s %d IN %s %s", dns.Fqdn(rr.Name), rr.Ttl, rr.Type, value) + r, err = dns.NewRR(rfc1035) + if err != nil { + return fmt.Errorf("failed to parse resource record: %v", err) + } + } + + z.Insert(r) + } + return nil +} + +// 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 *CloudDNS) updateZones(ctx context.Context) error { + errc := make(chan error) + defer close(errc) + for zName, z := range h.zones { + go func(zName string, z []*zone) { + var err error + var rrListResponse *gcp.ResourceRecordSetsListResponse + defer func() { + errc <- err + }() + + for i, hostedZone := range z { + newZ := file.NewZone(zName, "") + newZ.Upstream = h.upstream + rrListResponse, err = h.client.listRRSets(hostedZone.projectName, hostedZone.zoneName) + if err != nil { + err = fmt.Errorf("failed to list resource records for %v:%v:%v from gcp: %v", zName, hostedZone.projectName, hostedZone.zoneName, err) + return + } + updateZoneFromRRS(rrListResponse, newZ) + + h.zMu.Lock() + (*z[i]).z = newZ + h.zMu.Unlock() + } + + }(zName, z) + } + // 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()) + } + } + if len(errs) != 0 { + return fmt.Errorf("errors updating zones: %v", errs) + } + return nil +} + +// Name implements the Handler interface. +func (h *CloudDNS) Name() string { return "clouddns" } diff --git a/plugin/clouddns/clouddns_test.go b/plugin/clouddns/clouddns_test.go new file mode 100644 index 000000000..dafd65bba --- /dev/null +++ b/plugin/clouddns/clouddns_test.go @@ -0,0 +1,316 @@ +package clouddns + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/pkg/fall" + "github.com/coredns/coredns/plugin/pkg/upstream" + "github.com/coredns/coredns/plugin/test" + crequest "github.com/coredns/coredns/request" + + "github.com/miekg/dns" + gcp "google.golang.org/api/dns/v1" +) + +type fakeGCPClient struct { + *gcp.Service +} + +func (c fakeGCPClient) zoneExists(projectName, hostedZoneName string) error { + return nil +} + +func (c fakeGCPClient) listRRSets(projectName, hostedZoneName string) (*gcp.ResourceRecordSetsListResponse, error) { + if projectName == "bad-project" || hostedZoneName == "bad-zone" { + return nil, errors.New("the 'parameters.managedZone' resource named 'bad-zone' does not exist") + } + + var rr []*gcp.ResourceRecordSet + + if hostedZoneName == "sample-zone-1" { + rr = []*gcp.ResourceRecordSet{ + { + Name: "example.org.", + Ttl: 300, + Type: "A", + Rrdatas: []string{"1.2.3.4"}, + }, + { + Name: "www.example.org", + Ttl: 300, + Type: "A", + Rrdatas: []string{"1.2.3.4"}, + }, + { + Name: "*.www.example.org", + Ttl: 300, + Type: "CNAME", + Rrdatas: []string{"www.example.org"}, + }, + { + Name: "example.org.", + Ttl: 300, + Type: "AAAA", + Rrdatas: []string{"2001:db8:85a3::8a2e:370:7334"}, + }, + { + Name: "sample.example.org", + Ttl: 300, + Type: "CNAME", + Rrdatas: []string{"example.org"}, + }, + { + Name: "example.org.", + Ttl: 300, + Type: "PTR", + Rrdatas: []string{"ptr.example.org."}, + }, + { + Name: "org.", + Ttl: 300, + Type: "SOA", + Rrdatas: []string{"ns-cloud-c1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"}, + }, + { + Name: "com.", + Ttl: 300, + Type: "NS", + Rrdatas: []string{"ns-cloud-c4.googledomains.com."}, + }, + { + Name: "split-example.gov.", + Ttl: 300, + Type: "A", + Rrdatas: []string{"1.2.3.4"}, + }, + { + Name: "swag.", + Ttl: 300, + Type: "YOLO", + Rrdatas: []string{"foobar"}, + }, + } + } else { + rr = []*gcp.ResourceRecordSet{ + { + Name: "split-example.org.", + Ttl: 300, + Type: "A", + Rrdatas: []string{"1.2.3.4"}, + }, + { + Name: "other-example.org.", + Ttl: 300, + Type: "A", + Rrdatas: []string{"3.5.7.9"}, + }, + { + Name: "org.", + Ttl: 300, + Type: "SOA", + Rrdatas: []string{"ns-cloud-e1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"}, + }, + } + } + + return &gcp.ResourceRecordSetsListResponse{Rrsets: rr}, nil +} + +func TestCloudDNS(t *testing.T) { + ctx := context.Background() + + r, err := New(ctx, fakeGCPClient{}, map[string][]string{"bad.": {"bad-project:bad-zone"}}, &upstream.Upstream{}) + if err != nil { + t.Fatalf("Failed to create Cloud DNS: %v", err) + } + if err = r.Run(ctx); err == nil { + t.Fatalf("Expected errors for zone bad.") + } + + r, err = New(ctx, fakeGCPClient{}, map[string][]string{"org.": {"sample-project-1:sample-zone-2", "sample-project-1:sample-zone-1"}, "gov.": {"sample-project-1:sample-zone-2", "sample-project-1:sample-zone-1"}}, &upstream.Upstream{}) + if err != nil { + t.Fatalf("Failed to create Cloud DNS: %v", err) + } + r.Fall = fall.Zero + r.Fall.SetZonesFromArgs([]string{"gov."}) + r.Next = test.HandlerFunc(func(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { + state := crequest.Request{W: w, Req: r} + qname := state.Name() + m := new(dns.Msg) + rcode := dns.RcodeServerFailure + if qname == "example.gov." { + m.SetReply(r) + rr, err := dns.NewRR("example.gov. 300 IN A 2.4.6.8") + if err != nil { + t.Fatalf("Failed to create Resource Record: %v", err) + } + m.Answer = []dns.RR{rr} + + m.Authoritative = true + rcode = dns.RcodeSuccess + + } + + m.SetRcode(r, rcode) + w.WriteMsg(m) + return rcode, nil + }) + err = r.Run(ctx) + if err != nil { + t.Fatalf("Failed to initialize Cloud DNS: %v", err) + } + + tests := []struct { + qname string + qtype uint16 + wantRetCode int + wantAnswer []string // ownernames for the records in the additional section. + wantMsgRCode int + wantNS []string + expectedErr error + }{ + // 0. example.org A found - success. + { + qname: "example.org", + qtype: dns.TypeA, + wantAnswer: []string{"example.org. 300 IN A 1.2.3.4"}, + }, + // 1. example.org AAAA found - success. + { + qname: "example.org", + qtype: dns.TypeAAAA, + 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, + 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, + 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, + wantAnswer: []string{"sample.example.org. 300 IN CNAME example.org."}, + }, + // 5. Explicit SOA query for example.org. + { + qname: "example.org", + qtype: dns.TypeSOA, + wantAnswer: []string{"org. 300 IN SOA ns-cloud-e1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"}, + }, + // 6. Explicit SOA query for example.org. + { + qname: "example.org", + qtype: dns.TypeNS, + wantNS: []string{"org. 300 IN SOA ns-cloud-c1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"}, + }, + // 7. AAAA query for split-example.org must return NODATA. + { + qname: "split-example.gov", + qtype: dns.TypeAAAA, + wantRetCode: dns.RcodeSuccess, + wantNS: []string{"org. 300 IN SOA ns-cloud-c1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"}, + }, + // 8. Zone not configured. + { + qname: "badexample.com", + qtype: dns.TypeA, + wantRetCode: dns.RcodeServerFailure, + wantMsgRCode: dns.RcodeServerFailure, + }, + // 9. No record found. Return SOA record. + { + qname: "bad.org", + qtype: dns.TypeA, + wantRetCode: dns.RcodeSuccess, + wantMsgRCode: dns.RcodeNameError, + wantNS: []string{"org. 300 IN SOA ns-cloud-c1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"}, + }, + // 10. No record found. Fallthrough. + { + qname: "example.gov", + qtype: dns.TypeA, + wantAnswer: []string{"example.gov. 300 IN A 2.4.6.8"}, + }, + // 11. other-zone.example.org is stored in a different hosted zone. success + { + qname: "other-example.org", + qtype: dns.TypeA, + wantAnswer: []string{"other-example.org. 300 IN A 3.5.7.9"}, + }, + // 12. split-example.org only has A record. Expect NODATA. + { + qname: "split-example.org", + qtype: dns.TypeAAAA, + wantNS: []string{"org. 300 IN SOA ns-cloud-e1.googledomains.com. cloud-dns-hostmaster.google.com. 1 21600 300 259200 300"}, + }, + // 13. *.www.example.org is a wildcard CNAME to www.example.org. + { + qname: "a.www.example.org", + qtype: dns.TypeA, + wantAnswer: []string{ + "a.www.example.org. 300 IN CNAME www.example.org.", + "www.example.org. 300 IN A 1.2.3.4", + }, + }, + } + + for ti, tc := range tests { + req := new(dns.Msg) + req.SetQuestion(dns.Fqdn(tc.qname), tc.qtype) + + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + code, err := r.ServeDNS(ctx, rec, req) + + if err != tc.expectedErr { + t.Fatalf("Test %d: Expected error %v, but got %v", ti, tc.expectedErr, err) + } + if code != int(tc.wantRetCode) { + t.Fatalf("Test %d: Expected returned status code %s, but got %s", ti, dns.RcodeToString[tc.wantRetCode], dns.RcodeToString[code]) + } + + if tc.wantMsgRCode != rec.Msg.Rcode { + t.Errorf("Test %d: Unexpected msg status code. Want: %s, got: %s", ti, dns.RcodeToString[tc.wantMsgRCode], dns.RcodeToString[rec.Msg.Rcode]) + } + + 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 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.\nWant: %v\nGot: %v", ti, tc.wantNS[i], got) + } + } + } + } +} diff --git a/plugin/clouddns/gcp.go b/plugin/clouddns/gcp.go new file mode 100644 index 000000000..6d9d85d43 --- /dev/null +++ b/plugin/clouddns/gcp.go @@ -0,0 +1,32 @@ +package clouddns + +import gcp "google.golang.org/api/dns/v1" + +type gcpDNS interface { + zoneExists(projectName, hostedZoneName string) error + listRRSets(projectName, hostedZoneName string) (*gcp.ResourceRecordSetsListResponse, error) +} + +type gcpClient struct { + *gcp.Service +} + +// zoneExists is a wrapper method around `gcp.Service.ManagedZones.Get` +// it checks if the provided zone name for a given project exists. +func (c gcpClient) zoneExists(projectName, hostedZoneName string) error { + _, err := c.ManagedZones.Get(projectName, hostedZoneName).Do() + if err != nil { + return err + } + return nil +} + +// listRRSets is a wrapper method around `gcp.Service.ResourceRecordSets.List` +// it fetches and returns the record sets for a hosted zone. +func (c gcpClient) listRRSets(projectName, hostedZoneName string) (*gcp.ResourceRecordSetsListResponse, error) { + rr, err := c.ResourceRecordSets.List(projectName, hostedZoneName).Do() + if err != nil { + return nil, err + } + return rr, nil +} diff --git a/plugin/clouddns/log_test.go b/plugin/clouddns/log_test.go new file mode 100644 index 000000000..148635b4b --- /dev/null +++ b/plugin/clouddns/log_test.go @@ -0,0 +1,5 @@ +package clouddns + +import clog "github.com/coredns/coredns/plugin/pkg/log" + +func init() { clog.Discard() } diff --git a/plugin/clouddns/setup.go b/plugin/clouddns/setup.go new file mode 100644 index 000000000..732c240f7 --- /dev/null +++ b/plugin/clouddns/setup.go @@ -0,0 +1,110 @@ +package clouddns + +import ( + "context" + "strings" + + "github.com/coredns/coredns/core/dnsserver" + "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/fall" + clog "github.com/coredns/coredns/plugin/pkg/log" + "github.com/coredns/coredns/plugin/pkg/upstream" + + "github.com/caddyserver/caddy" + gcp "google.golang.org/api/dns/v1" + "google.golang.org/api/option" +) + +var log = clog.NewWithPlugin("clouddns") + +func init() { + caddy.RegisterPlugin("clouddns", caddy.Plugin{ + ServerType: "dns", + Action: func(c *caddy.Controller) error { + f := func(ctx context.Context, opt option.ClientOption) (gcpDNS, error) { + var err error + var client *gcp.Service + if opt != nil { + client, err = gcp.NewService(ctx, opt) + } else { + // if credentials file is not provided in the Corefile + // authenticate the client using env variables + client, err = gcp.NewService(ctx) + } + return gcpClient{client}, err + } + return setup(c, f) + }, + }) +} + +func setup(c *caddy.Controller, f func(ctx context.Context, opt option.ClientOption) (gcpDNS, error)) error { + for c.Next() { + keyPairs := map[string]struct{}{} + keys := map[string][]string{} + + var fall fall.F + up := upstream.New() + + args := c.RemainingArgs() + + for i := 0; i < len(args); i++ { + parts := strings.SplitN(args[i], ":", 3) + if len(parts) != 3 { + return c.Errf("invalid zone '%s'", args[i]) + } + dnsName, projectName, hostedZone := parts[0], parts[1], parts[2] + if dnsName == "" || projectName == "" || hostedZone == "" { + return c.Errf("invalid zone '%s'", args[i]) + } + if _, ok := keyPairs[args[i]]; ok { + return c.Errf("conflict zone '%s'", args[i]) + } + + keyPairs[args[i]] = struct{}{} + keys[dnsName] = append(keys[dnsName], projectName+":"+hostedZone) + } + + var opt option.ClientOption + for c.NextBlock() { + switch c.Val() { + case "upstream": + c.RemainingArgs() // eats args + // if filepath is provided in the Corefile use it to authenticate the dns client + case "credentials": + if c.NextArg() { + opt = option.WithCredentialsFile(c.Val()) + } else { + return c.ArgErr() + } + case "fallthrough": + fall.SetZonesFromArgs(c.RemainingArgs()) + default: + return c.Errf("unknown property '%s'", c.Val()) + } + } + + ctx := context.Background() + client, err := f(ctx, opt) + if err != nil { + return err + } + + h, err := New(ctx, client, keys, up) + if err != nil { + return c.Errf("failed to create Cloud DNS plugin: %v", err) + } + h.Fall = fall + + if err := h.Run(ctx); err != nil { + return c.Errf("failed to initialize Cloud DNS plugin: %v", err) + } + + dnsserver.GetConfig(c).AddPlugin(func(next plugin.Handler) plugin.Handler { + h.Next = next + return h + }) + } + + return nil +} diff --git a/plugin/clouddns/setup_test.go b/plugin/clouddns/setup_test.go new file mode 100644 index 000000000..be9c51d92 --- /dev/null +++ b/plugin/clouddns/setup_test.go @@ -0,0 +1,48 @@ +package clouddns + +import ( + "context" + "testing" + + "github.com/caddyserver/caddy" + "google.golang.org/api/option" +) + +func TestSetupCloudDNS(t *testing.T) { + f := func(ctx context.Context, opt option.ClientOption) (gcpDNS, error) { + return fakeGCPClient{}, nil + } + + tests := []struct { + body string + expectedError bool + }{ + {`clouddns`, false}, + {`clouddns :`, true}, + {`clouddns ::`, true}, + {`clouddns example.org.:example-project:zone-name`, false}, + {`clouddns example.org.:example-project:zone-name { }`, false}, + {`clouddns example.org.:example-project: { }`, true}, + {`clouddns example.org.:example-project:zone-name { }`, false}, + {`clouddns example.org.:example-project:zone-name { wat +}`, true}, + {`clouddns example.org.:example-project:zone-name { + fallthrough +}`, false}, + {`clouddns example.org.:example-project:zone-name { + credentials +}`, true}, + {`clouddns example.org.:example-project:zone-name example.org.:example-project:zone-name { + }`, true}, + + {`clouddns example.org { + }`, true}, + } + + for _, test := range tests { + c := caddy.NewTestController("dns", test.body) + if err := setup(c, f); (err == nil) == test.expectedError { + t.Errorf("Unexpected errors: %v", err) + } + } +}