Add Google Cloud DNS plugin (#3011)
Signed-off-by: Palash Nigam <npalash25@gmail.com> Closes: #2822
This commit is contained in:
parent
bde393096f
commit
194b0f95b4
13 changed files with 825 additions and 0 deletions
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal file
|
@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -37,6 +37,7 @@ var Directives = []string{
|
||||||
"hosts",
|
"hosts",
|
||||||
"route53",
|
"route53",
|
||||||
"azure",
|
"azure",
|
||||||
|
"clouddns",
|
||||||
"federation",
|
"federation",
|
||||||
"k8s_external",
|
"k8s_external",
|
||||||
"kubernetes",
|
"kubernetes",
|
||||||
|
|
|
@ -13,6 +13,7 @@ import (
|
||||||
_ "github.com/coredns/coredns/plugin/cache"
|
_ "github.com/coredns/coredns/plugin/cache"
|
||||||
_ "github.com/coredns/coredns/plugin/cancel"
|
_ "github.com/coredns/coredns/plugin/cancel"
|
||||||
_ "github.com/coredns/coredns/plugin/chaos"
|
_ "github.com/coredns/coredns/plugin/chaos"
|
||||||
|
_ "github.com/coredns/coredns/plugin/clouddns"
|
||||||
_ "github.com/coredns/coredns/plugin/debug"
|
_ "github.com/coredns/coredns/plugin/debug"
|
||||||
_ "github.com/coredns/coredns/plugin/dnssec"
|
_ "github.com/coredns/coredns/plugin/dnssec"
|
||||||
_ "github.com/coredns/coredns/plugin/dnstap"
|
_ "github.com/coredns/coredns/plugin/dnstap"
|
||||||
|
|
1
go.mod
1
go.mod
|
@ -58,6 +58,7 @@ require (
|
||||||
golang.org/x/net v0.0.0-20190628185345-da137c7871d7 // indirect
|
golang.org/x/net v0.0.0-20190628185345-da137c7871d7 // indirect
|
||||||
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3
|
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 // indirect
|
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/genproto v0.0.0-20190701230453-710ae3a149df // indirect
|
||||||
google.golang.org/grpc v1.22.0
|
google.golang.org/grpc v1.22.0
|
||||||
gopkg.in/DataDog/dd-trace-go.v1 v1.16.1
|
gopkg.in/DataDog/dd-trace-go.v1 v1.16.1
|
||||||
|
|
1
go.sum
1
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 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=
|
||||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
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.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/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 h1:+RRA9JqSOZFfKrOeqr2z77+8R2RKyh8PG66dcu1V0ck=
|
||||||
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
|
github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
|
||||||
|
|
|
@ -45,7 +45,11 @@ autopath:autopath
|
||||||
template:template
|
template:template
|
||||||
hosts:hosts
|
hosts:hosts
|
||||||
route53:route53
|
route53:route53
|
||||||
|
<<<<<<< 6a6e9a9b33731656b51655072951649d9e716613
|
||||||
azure:azure
|
azure:azure
|
||||||
|
=======
|
||||||
|
clouddns:clouddns
|
||||||
|
>>>>>>> Add Google Cloud DNS plugin
|
||||||
federation:federation
|
federation:federation
|
||||||
k8s_external:k8s_external
|
k8s_external:k8s_external
|
||||||
kubernetes:kubernetes
|
kubernetes:kubernetes
|
||||||
|
|
67
plugin/clouddns/README.md
Normal file
67
plugin/clouddns/README.md
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
~~~
|
222
plugin/clouddns/clouddns.go
Normal file
222
plugin/clouddns/clouddns.go
Normal file
|
@ -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" }
|
316
plugin/clouddns/clouddns_test.go
Normal file
316
plugin/clouddns/clouddns_test.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
plugin/clouddns/gcp.go
Normal file
32
plugin/clouddns/gcp.go
Normal file
|
@ -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
|
||||||
|
}
|
5
plugin/clouddns/log_test.go
Normal file
5
plugin/clouddns/log_test.go
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
package clouddns
|
||||||
|
|
||||||
|
import clog "github.com/coredns/coredns/plugin/pkg/log"
|
||||||
|
|
||||||
|
func init() { clog.Discard() }
|
110
plugin/clouddns/setup.go
Normal file
110
plugin/clouddns/setup.go
Normal file
|
@ -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
|
||||||
|
}
|
48
plugin/clouddns/setup_test.go
Normal file
48
plugin/clouddns/setup_test.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue