mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2025-08-05 17:16:59 +02:00
484 lines
15 KiB
Go
484 lines
15 KiB
Go
/*
|
|
Copyright 2018 The Kubernetes Authors.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package pdns
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
pgo "github.com/ffledgling/pdns-go"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"sigs.k8s.io/external-dns/endpoint"
|
|
"sigs.k8s.io/external-dns/pkg/tlsutils"
|
|
"sigs.k8s.io/external-dns/plan"
|
|
"sigs.k8s.io/external-dns/provider"
|
|
)
|
|
|
|
type pdnsChangeType string
|
|
|
|
const (
|
|
apiBase = "/api/v1"
|
|
|
|
// Unless we use something like pdnsproxy (discontinued upstream), this value will _always_ be localhost
|
|
defaultServerID = "localhost"
|
|
defaultTTL = 300
|
|
|
|
// PdnsDelete and PdnsReplace are effectively an enum for "pgo.RrSet.changetype"
|
|
// TODO: Can we somehow get this from the pgo swagger client library itself?
|
|
|
|
// PdnsDelete : PowerDNS changetype used for deleting rrsets
|
|
// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#rrset (see "changetype")
|
|
PdnsDelete pdnsChangeType = "DELETE"
|
|
// PdnsReplace : PowerDNS changetype for creating, updating and patching rrsets
|
|
PdnsReplace pdnsChangeType = "REPLACE"
|
|
// Number of times to retry failed PDNS requests
|
|
retryLimit = 3
|
|
// time in milliseconds
|
|
retryAfterTime = 250 * time.Millisecond
|
|
)
|
|
|
|
// PDNSConfig is comprised of the fields necessary to create a new PDNSProvider
|
|
type PDNSConfig struct {
|
|
DomainFilter endpoint.DomainFilter
|
|
DryRun bool
|
|
Server string
|
|
APIKey string
|
|
TLSConfig TLSConfig
|
|
}
|
|
|
|
// TLSConfig is comprised of the TLS-related fields necessary to create a new PDNSProvider
|
|
type TLSConfig struct {
|
|
TLSEnabled bool
|
|
CAFilePath string
|
|
ClientCertFilePath string
|
|
ClientCertKeyFilePath string
|
|
}
|
|
|
|
func (tlsConfig *TLSConfig) setHTTPClient(pdnsClientConfig *pgo.Configuration) error {
|
|
if !tlsConfig.TLSEnabled {
|
|
log.Debug("Skipping TLS for PDNS Provider.")
|
|
return nil
|
|
}
|
|
|
|
log.Debug("Configuring TLS for PDNS Provider.")
|
|
if tlsConfig.CAFilePath == "" {
|
|
return errors.New("certificate authority file path must be specified if TLS is enabled")
|
|
}
|
|
|
|
tlsClientConfig, err := tlsutils.NewTLSConfig(tlsConfig.ClientCertFilePath, tlsConfig.ClientCertKeyFilePath, tlsConfig.CAFilePath, "", false, tls.VersionTLS12)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Timeouts taken from net.http.DefaultTransport
|
|
transporter := &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
DualStack: true,
|
|
}).DialContext,
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
TLSClientConfig: tlsClientConfig,
|
|
}
|
|
pdnsClientConfig.HTTPClient = &http.Client{
|
|
Transport: transporter,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Function for debug printing
|
|
func stringifyHTTPResponseBody(r *http.Response) (body string) {
|
|
if r == nil {
|
|
return ""
|
|
}
|
|
|
|
buf := new(bytes.Buffer)
|
|
buf.ReadFrom(r.Body)
|
|
body = buf.String()
|
|
return body
|
|
}
|
|
|
|
// PDNSAPIProvider : Interface used and extended by the PDNSAPIClient struct as
|
|
// well as mock APIClients used in testing
|
|
type PDNSAPIProvider interface {
|
|
ListZones() ([]pgo.Zone, *http.Response, error)
|
|
PartitionZones(zones []pgo.Zone) ([]pgo.Zone, []pgo.Zone)
|
|
ListZone(zoneID string) (pgo.Zone, *http.Response, error)
|
|
PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error)
|
|
}
|
|
|
|
// PDNSAPIClient : Struct that encapsulates all the PowerDNS specific implementation details
|
|
type PDNSAPIClient struct {
|
|
dryRun bool
|
|
authCtx context.Context
|
|
client *pgo.APIClient
|
|
domainFilter endpoint.DomainFilter
|
|
}
|
|
|
|
// ListZones : Method returns all enabled zones from PowerDNS
|
|
// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#get--servers-server_id-zones
|
|
func (c *PDNSAPIClient) ListZones() (zones []pgo.Zone, resp *http.Response, err error) {
|
|
for i := 0; i < retryLimit; i++ {
|
|
zones, resp, err = c.client.ZonesApi.ListZones(c.authCtx, defaultServerID)
|
|
if err != nil {
|
|
log.Debugf("Unable to fetch zones %v", err)
|
|
log.Debugf("Retrying ListZones() ... %d", i)
|
|
time.Sleep(retryAfterTime * (1 << uint(i)))
|
|
continue
|
|
}
|
|
return zones, resp, err
|
|
}
|
|
|
|
log.Errorf("Unable to fetch zones. %v", err)
|
|
return zones, resp, err
|
|
}
|
|
|
|
// PartitionZones : Method returns a slice of zones that adhere to the domain filter and a slice of ones that does not adhere to the filter
|
|
func (c *PDNSAPIClient) PartitionZones(zones []pgo.Zone) (filteredZones []pgo.Zone, residualZones []pgo.Zone) {
|
|
if c.domainFilter.IsConfigured() {
|
|
for _, zone := range zones {
|
|
if c.domainFilter.Match(zone.Name) || c.domainFilter.MatchParent(zone.Name) {
|
|
filteredZones = append(filteredZones, zone)
|
|
} else {
|
|
residualZones = append(residualZones, zone)
|
|
}
|
|
}
|
|
} else {
|
|
filteredZones = zones
|
|
}
|
|
return filteredZones, residualZones
|
|
}
|
|
|
|
// ListZone : Method returns the details of a specific zone from PowerDNS
|
|
// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#get--servers-server_id-zones-zone_id
|
|
func (c *PDNSAPIClient) ListZone(zoneID string) (zone pgo.Zone, resp *http.Response, err error) {
|
|
for i := 0; i < retryLimit; i++ {
|
|
zone, resp, err = c.client.ZonesApi.ListZone(c.authCtx, defaultServerID, zoneID)
|
|
if err != nil {
|
|
log.Debugf("Unable to fetch zone %v", err)
|
|
log.Debugf("Retrying ListZone() ... %d", i)
|
|
time.Sleep(retryAfterTime * (1 << uint(i)))
|
|
continue
|
|
}
|
|
return zone, resp, err
|
|
}
|
|
|
|
log.Errorf("Unable to list zone. %v", err)
|
|
return zone, resp, err
|
|
}
|
|
|
|
// PatchZone : Method used to update the contents of a particular zone from PowerDNS
|
|
// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#patch--servers-server_id-zones-zone_id
|
|
func (c *PDNSAPIClient) PatchZone(zoneID string, zoneStruct pgo.Zone) (resp *http.Response, err error) {
|
|
for i := 0; i < retryLimit; i++ {
|
|
resp, err = c.client.ZonesApi.PatchZone(c.authCtx, defaultServerID, zoneID, zoneStruct)
|
|
if err != nil {
|
|
log.Debugf("Unable to patch zone %v", err)
|
|
log.Debugf("Retrying PatchZone() ... %d", i)
|
|
time.Sleep(retryAfterTime * (1 << uint(i)))
|
|
continue
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
log.Errorf("Unable to patch zone. %v", err)
|
|
return resp, err
|
|
}
|
|
|
|
// PDNSProvider is an implementation of the Provider interface for PowerDNS
|
|
type PDNSProvider struct {
|
|
provider.BaseProvider
|
|
client PDNSAPIProvider
|
|
}
|
|
|
|
// NewPDNSProvider initializes a new PowerDNS based Provider.
|
|
func NewPDNSProvider(ctx context.Context, config PDNSConfig) (*PDNSProvider, error) {
|
|
// Do some input validation
|
|
|
|
if config.APIKey == "" {
|
|
return nil, errors.New("missing API Key for PDNS. Specify using --pdns-api-key=")
|
|
}
|
|
|
|
// We do not support dry running, exit safely instead of surprising the user
|
|
// TODO: Add Dry Run support
|
|
if config.DryRun {
|
|
return nil, errors.New("PDNS Provider does not currently support dry-run")
|
|
}
|
|
|
|
if config.Server == "localhost" {
|
|
log.Warnf("PDNS Server is set to localhost, this may not be what you want. Specify using --pdns-server=")
|
|
}
|
|
|
|
pdnsClientConfig := pgo.NewConfiguration()
|
|
pdnsClientConfig.BasePath = config.Server + apiBase
|
|
if err := config.TLSConfig.setHTTPClient(pdnsClientConfig); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
provider := &PDNSProvider{
|
|
client: &PDNSAPIClient{
|
|
dryRun: config.DryRun,
|
|
authCtx: context.WithValue(ctx, pgo.ContextAPIKey, pgo.APIKey{Key: config.APIKey}),
|
|
client: pgo.NewAPIClient(pdnsClientConfig),
|
|
domainFilter: config.DomainFilter,
|
|
},
|
|
}
|
|
return provider, nil
|
|
}
|
|
|
|
func (p *PDNSProvider) convertRRSetToEndpoints(rr pgo.RrSet) (endpoints []*endpoint.Endpoint, _ error) {
|
|
endpoints = []*endpoint.Endpoint{}
|
|
targets := []string{}
|
|
|
|
for _, record := range rr.Records {
|
|
// If a record is "Disabled", it's not supposed to be "visible"
|
|
if !record.Disabled {
|
|
targets = append(targets, record.Content)
|
|
}
|
|
}
|
|
|
|
endpoints = append(endpoints, endpoint.NewEndpointWithTTL(rr.Name, rr.Type_, endpoint.TTL(rr.Ttl), targets...))
|
|
return endpoints, nil
|
|
}
|
|
|
|
// ConvertEndpointsToZones marshals endpoints into pdns compatible Zone structs
|
|
func (p *PDNSProvider) ConvertEndpointsToZones(eps []*endpoint.Endpoint, changetype pdnsChangeType) (zonelist []pgo.Zone, _ error) {
|
|
zonelist = []pgo.Zone{}
|
|
endpoints := make([]*endpoint.Endpoint, len(eps))
|
|
copy(endpoints, eps)
|
|
|
|
// Sort the endpoints array so we have deterministic inserts
|
|
sort.SliceStable(endpoints,
|
|
func(i, j int) bool {
|
|
// We only care about sorting endpoints with the same dnsname
|
|
if endpoints[i].DNSName == endpoints[j].DNSName {
|
|
return endpoints[i].RecordType < endpoints[j].RecordType
|
|
}
|
|
return endpoints[i].DNSName < endpoints[j].DNSName
|
|
})
|
|
|
|
zones, _, err := p.client.ListZones()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
filteredZones, residualZones := p.client.PartitionZones(zones)
|
|
|
|
// Sort the zone by length of the name in descending order, we use this
|
|
// property later to ensure we add a record to the longest matching zone
|
|
|
|
sort.SliceStable(filteredZones, func(i, j int) bool { return len(filteredZones[i].Name) > len(filteredZones[j].Name) })
|
|
|
|
// NOTE: Complexity of this loop is O(FilteredZones*Endpoints).
|
|
// A possibly faster implementation would be a search of the reversed
|
|
// DNSName in a trie of Zone names, which should be O(Endpoints), but at this point it's not
|
|
// necessary.
|
|
for _, zone := range filteredZones {
|
|
zone.Rrsets = []pgo.RrSet{}
|
|
for i := 0; i < len(endpoints); {
|
|
ep := endpoints[i]
|
|
dnsname := provider.EnsureTrailingDot(ep.DNSName)
|
|
if dnsname == zone.Name || strings.HasSuffix(dnsname, "."+zone.Name) {
|
|
// The assumption here is that there will only ever be one target
|
|
// per (ep.DNSName, ep.RecordType) tuple, which holds true for
|
|
// external-dns v5.0.0-alpha onwards
|
|
records := []pgo.Record{}
|
|
for _, t := range ep.Targets {
|
|
if ep.RecordType == "CNAME" {
|
|
t = provider.EnsureTrailingDot(t)
|
|
}
|
|
|
|
records = append(records, pgo.Record{Content: t})
|
|
}
|
|
rrset := pgo.RrSet{
|
|
Name: dnsname,
|
|
Type_: ep.RecordType,
|
|
Records: records,
|
|
Changetype: string(changetype),
|
|
}
|
|
|
|
// DELETEs explicitly forbid a TTL, therefore only PATCHes need the TTL
|
|
if changetype == PdnsReplace {
|
|
if int64(ep.RecordTTL) > int64(math.MaxInt32) {
|
|
return nil, errors.New("value of record TTL overflows, limited to int32")
|
|
}
|
|
if ep.RecordTTL == 0 {
|
|
// No TTL was specified for the record, we use the default
|
|
rrset.Ttl = int32(defaultTTL)
|
|
} else {
|
|
rrset.Ttl = int32(ep.RecordTTL)
|
|
}
|
|
}
|
|
|
|
zone.Rrsets = append(zone.Rrsets, rrset)
|
|
|
|
// "pop" endpoint if it's matched
|
|
endpoints = append(endpoints[0:i], endpoints[i+1:]...)
|
|
} else {
|
|
// If we didn't pop anything, we move to the next item in the list
|
|
i++
|
|
}
|
|
}
|
|
if len(zone.Rrsets) > 0 {
|
|
zonelist = append(zonelist, zone)
|
|
}
|
|
}
|
|
|
|
// residualZones is unsorted by name length like its counterpart
|
|
// since we only care to remove endpoints that do not match domain filter
|
|
for _, zone := range residualZones {
|
|
for i := 0; i < len(endpoints); {
|
|
ep := endpoints[i]
|
|
dnsname := provider.EnsureTrailingDot(ep.DNSName)
|
|
if dnsname == zone.Name || strings.HasSuffix(dnsname, "."+zone.Name) {
|
|
// "pop" endpoint if it's matched to a residual zone... essentially a no-op
|
|
log.Debugf("Ignoring Endpoint because it was matched to a zone that was not specified within Domain Filter(s): %s", dnsname)
|
|
endpoints = append(endpoints[0:i], endpoints[i+1:]...)
|
|
} else {
|
|
i++
|
|
}
|
|
}
|
|
}
|
|
// If we still have some endpoints left, it means we couldn't find a matching zone (filtered or residual) for them
|
|
// We warn instead of hard fail here because we don't want a misconfig to cause everything to go down
|
|
if len(endpoints) > 0 {
|
|
log.Warnf("No matching zones were found for the following endpoints: %+v", endpoints)
|
|
}
|
|
|
|
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, err := json.Marshal(zone)
|
|
if err != nil {
|
|
log.Errorf("JSON Marshal for zone struct failed!")
|
|
} else {
|
|
log.Debugf("Struct for PatchZone:\n%s", string(jso))
|
|
}
|
|
resp, err := p.client.PatchZone(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(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) {
|
|
zones, _, err := p.client.ListZones()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
filteredZones, _ := p.client.PartitionZones(zones)
|
|
|
|
for _, zone := range filteredZones {
|
|
z, _, err := p.client.ListZone(zone.Id)
|
|
if err != nil {
|
|
log.Warnf("Unable to fetch Records")
|
|
return nil, err
|
|
}
|
|
|
|
for _, rr := range z.Rrsets {
|
|
e, err := p.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(ctx context.Context, changes *plan.Changes) error {
|
|
startTime := time.Now()
|
|
|
|
// Create
|
|
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-existent records creates them
|
|
err := p.mutateRecords(changes.Create, PdnsReplace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Update
|
|
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 {
|
|
err := p.mutateRecords(changes.UpdateNew, PdnsReplace)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Delete
|
|
for _, change := range changes.Delete {
|
|
log.Debugf("DELETE: %+v", change)
|
|
}
|
|
if len(changes.Delete) > 0 {
|
|
err := p.mutateRecords(changes.Delete, PdnsDelete)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
log.Debugf("Changes pushed out to PowerDNS in %s\n", time.Since(startTime))
|
|
return nil
|
|
}
|