From ec0ecfbfad65910e039e8f3181064e7badcebd4e Mon Sep 17 00:00:00 2001 From: Anhad Jai Singh Date: Mon, 6 Nov 2017 21:06:53 +0530 Subject: [PATCH] Add PDNS Provider - Adds PowerDNS Authoritative Server as a provider - Adds `--pdns-server` and `--pdns-api-key` flags - Implements the PDNS provider - Modifies `types_test.go` to fix tests for aforementioned flags - `gofmt`ed and `golint`ed --- main.go | 2 + pkg/apis/externaldns/types.go | 8 +- pkg/apis/externaldns/types_test.go | 8 + provider/pdns.go | 295 +++++++++++++++++++++++++++++ 4 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 provider/pdns.go diff --git a/main.go b/main.go index cf32f958e4..b99d443dc6 100644 --- a/main.go +++ b/main.go @@ -118,6 +118,8 @@ func main() { ) case "inmemory": p, err = provider.NewInMemoryProvider(provider.InMemoryInitZones(cfg.InMemoryZones), provider.InMemoryWithDomain(domainFilter), provider.InMemoryWithLogging()), nil + case "pdns": + p, err = provider.NewPDNSProvider(cfg.PDNSServer, cfg.PDNSAPIKey, domainFilter, cfg.DryRun) default: log.Fatalf("unknown dns provider: %s", cfg.Provider) } diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 857a7afa9a..df983383d7 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -51,6 +51,8 @@ type Config struct { InfobloxWapiVersion string InfobloxSSLVerify bool InMemoryZones []string + PDNSServer string + PDNSAPIKey string Policy string Registry string TXTOwnerID string @@ -85,6 +87,8 @@ var defaultConfig = &Config{ InfobloxWapiVersion: "2.3.1", InfobloxSSLVerify: true, InMemoryZones: []string{}, + PDNSServer: "http://localhost:8081", + PDNSAPIKey: "", Policy: "sync", Registry: "txt", TXTOwnerID: "default", @@ -129,7 +133,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("publish-internal-services", "Allow external-dns to publish DNS records for ClusterIP services (optional)").BoolVar(&cfg.PublishInternal) // Flags related to providers - app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, google, azure, cloudflare, digitalocean, dnsimple, infoblox, inmemory)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "inmemory") + app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, google, azure, cloudflare, digitalocean, dnsimple, infoblox, inmemory)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "inmemory", "pdns") app.Flag("domain-filter", "Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)").Default("").StringsVar(&cfg.DomainFilter) app.Flag("google-project", "When using the Google provider, specify the Google project (required when --provider=google)").Default(defaultConfig.GoogleProject).StringVar(&cfg.GoogleProject) app.Flag("aws-zone-type", "When using the AWS provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AWSZoneType).EnumVar(&cfg.AWSZoneType, "", "public", "private") @@ -143,6 +147,8 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("infoblox-wapi-version", "When using the Infoblox provider, specify the WAPI version (default: 2.3.1)").Default(defaultConfig.InfobloxWapiVersion).StringVar(&cfg.InfobloxWapiVersion) app.Flag("infoblox-ssl-verify", "When using the Infoblox provider, specify whether to verify the SSL certificate (default: true)").Default(strconv.FormatBool(defaultConfig.InfobloxSSLVerify)).BoolVar(&cfg.InfobloxSSLVerify) app.Flag("inmemory-zone", "Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.InMemoryZones) + app.Flag("pdns-server", "When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)").Default(defaultConfig.PDNSServer).StringVar(&cfg.PDNSServer) + app.Flag("pdns-api-key", "When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)").Default(defaultConfig.PDNSAPIKey).StringVar(&cfg.PDNSAPIKey) // Flags related to policies app.Flag("policy", "Modify how DNS records are sychronized between sources and providers (default: sync, options: sync, upsert-only)").Default(defaultConfig.Policy).EnumVar(&cfg.Policy, "sync", "upsert-only") diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index d723427fd3..444d28356d 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -48,6 +48,8 @@ var ( InfobloxWapiVersion: "2.3.1", InfobloxSSLVerify: true, InMemoryZones: []string{""}, + PDNSServer: "http://localhost:8081", + PDNSAPIKey: "", Policy: "sync", Registry: "txt", TXTOwnerID: "default", @@ -81,6 +83,8 @@ var ( InfobloxWapiVersion: "2.6.1", InfobloxSSLVerify: false, InMemoryZones: []string{"example.org", "company.com"}, + PDNSServer: "http://ns.example.com:8081", + PDNSAPIKey: "some-secret-key", Policy: "upsert-only", Registry: "noop", TXTOwnerID: "owner-1", @@ -132,6 +136,8 @@ func TestParseFlags(t *testing.T) { "--infoblox-wapi-version=2.6.1", "--inmemory-zone=example.org", "--inmemory-zone=company.com", + "--pdns-server=http://ns.example.com:8081", + "--pdns-api-key=some-secret-key", "--no-infoblox-ssl-verify", "--domain-filter=example.org", "--domain-filter=company.com", @@ -172,6 +178,8 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_INFOBLOX_WAPI_VERSION": "2.6.1", "EXTERNAL_DNS_INFOBLOX_SSL_VERIFY": "0", "EXTERNAL_DNS_INMEMORY_ZONE": "example.org\ncompany.com", + "EXTERNAL_DNS_PDNS_SERVER": "http://ns.example.com:8081", + "EXTERNAL_DNS_PDNS_API_KEY": "some-secret-key", "EXTERNAL_DNS_DOMAIN_FILTER": "example.org\ncompany.com", "EXTERNAL_DNS_AWS_ZONE_TYPE": "private", "EXTERNAL_DNS_POLICY": "upsert-only", diff --git a/provider/pdns.go b/provider/pdns.go new file mode 100644 index 0000000000..ee16b35a64 --- /dev/null +++ b/provider/pdns.go @@ -0,0 +1,295 @@ +package provider + +import ( + //"strings" + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + pgo "github.com/kubernetes-incubator/external-dns/provider/internal/pdns-go" +) + +type pdnsChangeType string + +const ( + apiBase = "/api/v1" + + // Unless we use something like pdnsproxy (discontinued upsteam), this value will _always_ be localhost + defaultServerID = "localhost" + defaultTTL = 300 + + maxUInt32 = ^uint32(0) + maxInt32 = maxUInt32 >> 1 + + // This is effectively an enum for "pgo.RrSet.changetype" + // TODO: Can we somehow get this from the pgo swagger client library itself? + pdnsDelete pdnsChangeType = "DELETE" + pdnsReplace pdnsChangeType = "REPLACE" +) + +// PDNSProvider is an implementation of the Provider interface for PowerDNS +type PDNSProvider struct { + dryRun bool + // only consider hosted zones managing domains ending in this suffix + domainFilter DomainFilter + // filter hosted zones by type (e.g. private or public) + zoneTypeFilter ZoneTypeFilter + + // Swagger API Client + client *pgo.APIClient + + // Auth context to be passed to client requests, contains API keys etc. + authCtx context.Context +} + +// Function for debug printing +func stringifyHTTPResponseBody(r *http.Response) (body string) { + + buf := new(bytes.Buffer) + buf.ReadFrom(r.Body) + body = buf.String() + return body + +} + +// NewPDNSProvider initializes a new PowerDNS based Provider. +func NewPDNSProvider(server string, apikey string, domainFilter DomainFilter, dryRun bool) (*PDNSProvider, error) { + + // Do some input validation + + // We do not support dry running, exit safely instead of surprising the user + // TODO: Add Dry Run support + if dryRun { + log.Fatalf("PDNS Provider does not currently support dry-run, stopping.") + } + + if server == "localhost" { + log.Warnf("PDNS Server is set to localhost, this is likely not what you want. Specify using --pdns-server=") + } + + if apikey == "" { + log.Warnf("API Key for PDNS is empty. Specify using --pdns-api-key=") + } + if len(domainFilter.filters) == 0 { + log.Warnf("Domain Filter is not supported by PDNS. It will be ignored.") + } + + provider := &PDNSProvider{} + + cfg := pgo.NewConfiguration() + cfg.Host = server + cfg.BasePath = server + apiBase + + // Initialize a single client that we can use for all requests + provider.client = pgo.NewAPIClient(cfg) + + // Configure PDNS API Key, which is sent via X-API-Key header to pdns server + provider.authCtx = context.WithValue(context.TODO(), pgo.ContextAPIKey, pgo.APIKey{Key: apikey}) + + return provider, nil +} + +func convertRRSetToEndpoints(rr pgo.RrSet) (endpoints []*endpoint.Endpoint, _ error) { + endpoints = []*endpoint.Endpoint{} + + for _, record := range rr.Records { + //func NewEndpointWithTTL(dnsName, target, recordType string, ttl TTL) *Endpoint + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(rr.Name, record.Content, rr.Type_, endpoint.TTL(rr.Ttl))) + } + + return endpoints, nil +} + +// Zones returns the list of all zones controlled by the pdns server as a list of pdns Zone structs +func (p *PDNSProvider) Zones() (zones []pgo.Zone, _ error) { + zones, _, err := p.client.ZonesApi.ListZones(p.authCtx, defaultServerID) + if err != nil { + log.Warnf("Unable to fetch zones. %v", err) + return nil, err + } + + return zones, nil + +} + +// convertEndpointsToZones marshals endpoints into pdns compatible Zone structs +func (p *PDNSProvider) convertEndpointsToZones(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) (zonelist []pgo.Zone, _ error) { + /* eg of mastermap + { "example.com": + { "app.example.com": + { "A": ["192.168.0.1", "8.8.8.8"] } + { "TXT": ["\"heritage=external-dns,external-dns/owner=example\""] } + } + } + */ + mastermap := make(map[string]map[string]map[string][]*endpoint.Endpoint) + zoneNameStructMap := map[string]pgo.Zone{} + + zones, err := p.Zones() + + if err != nil { + return nil, err + } + // Identify zones we control + for _, z := range zones { + mastermap[z.Name] = make(map[string]map[string][]*endpoint.Endpoint) + zoneNameStructMap[z.Name] = z + } + + for _, ep := range endpoints { + // Identify which zone an endpoint belongs to + dnsname := ensureTrailingDot(ep.DNSName) + zname := "" + for z := range mastermap { + if strings.HasSuffix(dnsname, z) && len(dnsname) > len(zname) { + zname = z + } + } + + // We can encounter a DNS name multiple times (different record types), we only create a map the first time + if _, ok := mastermap[zname][dnsname]; !ok { + mastermap[zname][dnsname] = make(map[string][]*endpoint.Endpoint) + } + + // We can get multiple targets for the same record type (eg. Multiple A records for a service) + if _, ok := mastermap[zname][dnsname][ep.RecordType]; !ok { + mastermap[zname][dnsname][ep.RecordType] = make([]*endpoint.Endpoint, 0) + } + + mastermap[zname][dnsname][ep.RecordType] = append(mastermap[zname][dnsname][ep.RecordType], ep) + + } + + for zname := range mastermap { + + zone := zoneNameStructMap[zname] + zone.Rrsets = []pgo.RrSet{} + for rrname := range mastermap[zname] { + for rtype := range mastermap[zname][rrname] { + rrset := pgo.RrSet{} + rrset.Name = rrname + rrset.Type_ = rtype + rrset.Changetype = string(changetype) + rttl := mastermap[zname][rrname][rtype][0].RecordTTL + if int64(rttl) > int64(maxInt32) { + return nil, errors.New("Value of record TTL overflows, limited to int32") + } + rrset.Ttl = int32(rttl) + records := []pgo.Record{} + for _, e := range mastermap[zname][rrname][rtype] { + records = append(records, pgo.Record{Content: e.Target}) + + } + rrset.Records = records + zone.Rrsets = append(zone.Rrsets, rrset) + } + + } + + // Skip the empty zones (likely ones we don't control) + if len(zone.Rrsets) > 0 { + zonelist = append(zonelist, zone) + } + + } + + log.Debugf("Zone List generated from Endpoints: %+v", zonelist) + + return zonelist, nil +} + +// mutateRecords takes a list of endpoints and creates, replaces or deletes them based on the changetype +func (p *PDNSProvider) mutateRecords(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) error { + zonelist, err := p.convertEndpointsToZones(endpoints, changetype) + if err != nil { + return err + } + for _, zone := range zonelist { + jso, _ := json.Marshal(zone) + log.Debugf("Struct for PatchZone:\n%s", string(jso)) + + resp, err := p.client.ZonesApi.PatchZone(p.authCtx, defaultServerID, zone.Id, zone) + if err != nil { + log.Debugf("PDNS API response: %s", stringifyHTTPResponseBody(resp)) + return err + } + + } + return nil +} + +// Records returns all DNS records controlled by the configured PDNS server (for all zones) +func (p *PDNSProvider) Records() (endpoints []*endpoint.Endpoint, _ error) { + + zones, err := p.Zones() + if err != nil { + return nil, err + } + + for _, zone := range zones { + z, _, err := p.client.ZonesApi.ListZone(p.authCtx, defaultServerID, zone.Id) + if err != nil { + log.Warnf("Unable to fetch data for %v. %v", zone.Id, err) + return nil, err + } + + for _, rr := range z.Rrsets { + e, err := convertRRSetToEndpoints(rr) + if err != nil { + return nil, err + } + endpoints = append(endpoints, e...) + } + } + + log.Debugf("Records fetched:\n%+v", endpoints) + return endpoints, nil +} + +// ApplyChanges takes a list of changes (endpoints) and updates the PDNS server +// by sending the correct HTTP PATCH requests to a matching zone +func (p *PDNSProvider) ApplyChanges(changes *plan.Changes) error { + + for _, change := range changes.Create { + log.Debugf("CREATE: %+v", change) + } + + // We only attempt to mutate records if there are any to mutate. A + // call to mutate records with an empty list of endpoints is still a + // valid call and a no-op, but we might as well not make the call to + // prevent unnecessary logging + if len(changes.Create) > 0 { + // "Replacing" non-existant records creates them + p.mutateRecords(changes.Create, pdnsReplace) + } + + for _, change := range changes.UpdateOld { + // Since PDNS "Patches", we don't need to specify the "old" + // record. The Update New change type will automatically take + // care of replacing the old RRSet with the new one We simply + // leave this logging here for information + log.Debugf("UPDATE-OLD (ignored): %+v", change) + } + + for _, change := range changes.UpdateNew { + log.Debugf("UPDATE-NEW: %+v", change) + } + if len(changes.UpdateNew) > 0 { + p.mutateRecords(changes.UpdateNew, pdnsReplace) + } + + for _, change := range changes.Delete { + log.Debugf("DELETE: %+v", change) + } + if len(changes.Delete) > 0 { + p.mutateRecords(changes.Delete, pdnsDelete) + } + return nil +}