mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2025-08-05 17:16:59 +02:00
473 lines
13 KiB
Go
473 lines
13 KiB
Go
/*
|
|
Copyright 2017 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 coredns
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"math/rand"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
etcdcv3 "go.etcd.io/etcd/clientv3"
|
|
|
|
"sigs.k8s.io/external-dns/endpoint"
|
|
"sigs.k8s.io/external-dns/plan"
|
|
"sigs.k8s.io/external-dns/provider"
|
|
)
|
|
|
|
func init() {
|
|
rand.Seed(time.Now().UnixNano())
|
|
}
|
|
|
|
const (
|
|
priority = 10 // default priority when nothing is set
|
|
etcdTimeout = 5 * time.Second
|
|
|
|
randomPrefixLabel = "prefix"
|
|
)
|
|
|
|
// coreDNSClient is an interface to work with CoreDNS service records in etcd
|
|
type coreDNSClient interface {
|
|
GetServices(prefix string) ([]*Service, error)
|
|
SaveService(value *Service) error
|
|
DeleteService(key string) error
|
|
}
|
|
|
|
type coreDNSProvider struct {
|
|
dryRun bool
|
|
coreDNSPrefix string
|
|
domainFilter endpoint.DomainFilter
|
|
client coreDNSClient
|
|
}
|
|
|
|
// Service represents CoreDNS etcd record
|
|
type Service struct {
|
|
Host string `json:"host,omitempty"`
|
|
Port int `json:"port,omitempty"`
|
|
Priority int `json:"priority,omitempty"`
|
|
Weight int `json:"weight,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Mail bool `json:"mail,omitempty"` // Be an MX record. Priority becomes Preference.
|
|
TTL uint32 `json:"ttl,omitempty"`
|
|
|
|
// When a SRV record with a "Host: IP-address" is added, we synthesize
|
|
// a srv.Target domain name. Normally we convert the full Key where
|
|
// the record lives to a DNS name and use this as the srv.Target. When
|
|
// TargetStrip > 0 we strip the left most TargetStrip labels from the
|
|
// DNS name.
|
|
TargetStrip int `json:"targetstrip,omitempty"`
|
|
|
|
// Group is used to group (or *not* to group) different services
|
|
// together. Services with an identical Group are returned in the same
|
|
// answer.
|
|
Group string `json:"group,omitempty"`
|
|
|
|
// Etcd key where we found this service and ignored from json un-/marshalling
|
|
Key string `json:"-"`
|
|
}
|
|
|
|
type etcdClient struct {
|
|
client *etcdcv3.Client
|
|
ctx context.Context
|
|
}
|
|
|
|
var _ coreDNSClient = etcdClient{}
|
|
|
|
// GetService return all Service records stored in etcd stored anywhere under the given key (recursively)
|
|
func (c etcdClient) GetServices(prefix string) ([]*Service, error) {
|
|
ctx, cancel := context.WithTimeout(c.ctx, etcdTimeout)
|
|
defer cancel()
|
|
|
|
path := prefix
|
|
r, err := c.client.Get(ctx, path, etcdcv3.WithPrefix())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var svcs []*Service
|
|
bx := make(map[Service]bool)
|
|
for _, n := range r.Kvs {
|
|
svc := new(Service)
|
|
if err := json.Unmarshal(n.Value, svc); err != nil {
|
|
return nil, fmt.Errorf("%s: %s", n.Key, err.Error())
|
|
}
|
|
b := Service{Host: svc.Host, Port: svc.Port, Priority: svc.Priority, Weight: svc.Weight, Text: svc.Text, Key: string(n.Key)}
|
|
if _, ok := bx[b]; ok {
|
|
// skip the service if already added to service list.
|
|
// the same service might be found in multiple etcd nodes.
|
|
continue
|
|
}
|
|
bx[b] = true
|
|
|
|
svc.Key = string(n.Key)
|
|
if svc.Priority == 0 {
|
|
svc.Priority = priority
|
|
}
|
|
svcs = append(svcs, svc)
|
|
}
|
|
|
|
return svcs, nil
|
|
}
|
|
|
|
// SaveService persists service data into etcd
|
|
func (c etcdClient) SaveService(service *Service) error {
|
|
ctx, cancel := context.WithTimeout(c.ctx, etcdTimeout)
|
|
defer cancel()
|
|
|
|
value, err := json.Marshal(&service)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = c.client.Put(ctx, service.Key, string(value))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteService deletes service record from etcd
|
|
func (c etcdClient) DeleteService(key string) error {
|
|
ctx, cancel := context.WithTimeout(c.ctx, etcdTimeout)
|
|
defer cancel()
|
|
|
|
_, err := c.client.Delete(ctx, key, etcdcv3.WithPrefix())
|
|
return err
|
|
}
|
|
|
|
// loads TLS artifacts and builds tls.Clonfig object
|
|
func newTLSConfig(certPath, keyPath, caPath, serverName string, insecure bool) (*tls.Config, error) {
|
|
if certPath != "" && keyPath == "" || certPath == "" && keyPath != "" {
|
|
return nil, errors.New("either both cert and key or none must be provided")
|
|
}
|
|
var certificates []tls.Certificate
|
|
if certPath != "" {
|
|
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not load TLS cert: %s", err)
|
|
}
|
|
certificates = append(certificates, cert)
|
|
}
|
|
roots, err := loadRoots(caPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &tls.Config{
|
|
Certificates: certificates,
|
|
RootCAs: roots,
|
|
InsecureSkipVerify: insecure,
|
|
ServerName: serverName,
|
|
}, nil
|
|
}
|
|
|
|
// loads CA cert
|
|
func loadRoots(caPath string) (*x509.CertPool, error) {
|
|
if caPath == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
roots := x509.NewCertPool()
|
|
pem, err := ioutil.ReadFile(caPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error reading %s: %s", caPath, err)
|
|
}
|
|
ok := roots.AppendCertsFromPEM(pem)
|
|
if !ok {
|
|
return nil, fmt.Errorf("could not read root certs: %s", err)
|
|
}
|
|
return roots, nil
|
|
}
|
|
|
|
// builds etcd client config depending on connection scheme and TLS parameters
|
|
func getETCDConfig() (*etcdcv3.Config, error) {
|
|
etcdURLsStr := os.Getenv("ETCD_URLS")
|
|
if etcdURLsStr == "" {
|
|
etcdURLsStr = "http://localhost:2379"
|
|
}
|
|
etcdURLs := strings.Split(etcdURLsStr, ",")
|
|
firstURL := strings.ToLower(etcdURLs[0])
|
|
if strings.HasPrefix(firstURL, "http://") {
|
|
return &etcdcv3.Config{Endpoints: etcdURLs}, nil
|
|
} else if strings.HasPrefix(firstURL, "https://") {
|
|
caFile := os.Getenv("ETCD_CA_FILE")
|
|
certFile := os.Getenv("ETCD_CERT_FILE")
|
|
keyFile := os.Getenv("ETCD_KEY_FILE")
|
|
serverName := os.Getenv("ETCD_TLS_SERVER_NAME")
|
|
isInsecureStr := strings.ToLower(os.Getenv("ETCD_TLS_INSECURE"))
|
|
isInsecure := isInsecureStr == "true" || isInsecureStr == "yes" || isInsecureStr == "1"
|
|
tlsConfig, err := newTLSConfig(certFile, keyFile, caFile, serverName, isInsecure)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &etcdcv3.Config{
|
|
Endpoints: etcdURLs,
|
|
TLS: tlsConfig,
|
|
}, nil
|
|
} else {
|
|
return nil, errors.New("etcd URLs must start with either http:// or https://")
|
|
}
|
|
}
|
|
|
|
//newETCDClient is an etcd client constructor
|
|
func newETCDClient() (coreDNSClient, error) {
|
|
cfg, err := getETCDConfig()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c, err := etcdcv3.New(*cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return etcdClient{c, context.Background()}, nil
|
|
}
|
|
|
|
// NewCoreDNSProvider is a CoreDNS provider constructor
|
|
func NewCoreDNSProvider(domainFilter endpoint.DomainFilter, prefix string, dryRun bool) (provider.Provider, error) {
|
|
client, err := newETCDClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return coreDNSProvider{
|
|
client: client,
|
|
dryRun: dryRun,
|
|
coreDNSPrefix: prefix,
|
|
domainFilter: domainFilter,
|
|
}, nil
|
|
}
|
|
|
|
// findEp takes an Endpoint slice and looks for an element in it. If found it will
|
|
// return Endpoint, otherwise it will return nil and a bool of false.
|
|
func findEp(slice []*endpoint.Endpoint, dnsName string) (*endpoint.Endpoint, bool) {
|
|
for _, item := range slice {
|
|
if item.DNSName == dnsName {
|
|
return item, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// findLabelInTargets takes an ep.Targets string slice and looks for an element in it. If found it will
|
|
// return its string value, otherwise it will return empty string and a bool of false.
|
|
func findLabelInTargets(targets []string, label string) (string, bool) {
|
|
for _, target := range targets {
|
|
if target == label {
|
|
return target, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// Records returns all DNS records found in CoreDNS etcd backend. Depending on the record fields
|
|
// it may be mapped to one or two records of type A, CNAME, TXT, A+TXT, CNAME+TXT
|
|
func (p coreDNSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
|
|
var result []*endpoint.Endpoint
|
|
services, err := p.client.GetServices(p.coreDNSPrefix)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, service := range services {
|
|
domains := strings.Split(strings.TrimPrefix(service.Key, p.coreDNSPrefix), "/")
|
|
reverse(domains)
|
|
dnsName := strings.Join(domains[service.TargetStrip:], ".")
|
|
if !p.domainFilter.Match(dnsName) {
|
|
continue
|
|
}
|
|
log.Debugf("Getting service (%v) with service host (%s)", service, service.Host)
|
|
prefix := strings.Join(domains[:service.TargetStrip], ".")
|
|
if service.Host != "" {
|
|
ep, found := findEp(result, dnsName)
|
|
if found {
|
|
ep.Targets = append(ep.Targets, service.Host)
|
|
log.Debugf("Extending ep (%s) with new service host (%s)", ep, service.Host)
|
|
} else {
|
|
ep = endpoint.NewEndpointWithTTL(
|
|
dnsName,
|
|
guessRecordType(service.Host),
|
|
endpoint.TTL(service.TTL),
|
|
service.Host,
|
|
)
|
|
log.Debugf("Creating new ep (%s) with new service host (%s)", ep, service.Host)
|
|
}
|
|
ep.Labels["originalText"] = service.Text
|
|
ep.Labels[randomPrefixLabel] = prefix
|
|
ep.Labels[service.Host] = prefix
|
|
result = append(result, ep)
|
|
}
|
|
if service.Text != "" {
|
|
ep := endpoint.NewEndpoint(
|
|
dnsName,
|
|
endpoint.RecordTypeTXT,
|
|
service.Text,
|
|
)
|
|
ep.Labels[randomPrefixLabel] = prefix
|
|
result = append(result, ep)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ApplyChanges stores changes back to etcd converting them to CoreDNS format and aggregating A/CNAME and TXT records
|
|
func (p coreDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
|
|
grouped := map[string][]*endpoint.Endpoint{}
|
|
for _, ep := range changes.Create {
|
|
grouped[ep.DNSName] = append(grouped[ep.DNSName], ep)
|
|
}
|
|
for i, ep := range changes.UpdateNew {
|
|
ep.Labels = changes.UpdateOld[i].Labels
|
|
log.Debugf("Updating labels (%s) with old labels(%s)", ep.Labels, changes.UpdateOld[i].Labels)
|
|
grouped[ep.DNSName] = append(grouped[ep.DNSName], ep)
|
|
}
|
|
for dnsName, group := range grouped {
|
|
if !p.domainFilter.Match(dnsName) {
|
|
log.Debugf("Skipping record %s because it was filtered out by the specified --domain-filter", dnsName)
|
|
continue
|
|
}
|
|
var services []Service
|
|
for _, ep := range group {
|
|
if ep.RecordType == endpoint.RecordTypeTXT {
|
|
continue
|
|
}
|
|
|
|
for _, target := range ep.Targets {
|
|
prefix := ep.Labels[target]
|
|
log.Debugf("Getting prefix(%s) from label(%s)", prefix, target)
|
|
if prefix == "" {
|
|
prefix = fmt.Sprintf("%08x", rand.Int31())
|
|
log.Infof("Generating new prefix: (%s)", prefix)
|
|
}
|
|
|
|
service := Service{
|
|
Host: target,
|
|
Text: ep.Labels["originalText"],
|
|
Key: p.etcdKeyFor(prefix + "." + dnsName),
|
|
TargetStrip: strings.Count(prefix, ".") + 1,
|
|
TTL: uint32(ep.RecordTTL),
|
|
}
|
|
services = append(services, service)
|
|
ep.Labels[target] = prefix
|
|
log.Debugf("Putting prefix(%s) to label(%s)", prefix, target)
|
|
log.Debugf("Ep labels structure now: (%v)", ep.Labels)
|
|
}
|
|
|
|
// Clean outdated targets
|
|
for label, labelPrefix := range ep.Labels {
|
|
// Skip non Target related labels
|
|
labelsToSkip := []string{"originalText", "prefix", "resource"}
|
|
if _, ok := findLabelInTargets(labelsToSkip, label); ok {
|
|
continue
|
|
}
|
|
|
|
log.Debugf("Finding label (%s) in targets(%v)", label, ep.Targets)
|
|
if _, ok := findLabelInTargets(ep.Targets, label); !ok {
|
|
log.Debugf("Found non existing label(%s) in targets(%v)", label, ep.Targets)
|
|
dnsName := ep.DNSName
|
|
dnsName = labelPrefix + "." + dnsName
|
|
key := p.etcdKeyFor(dnsName)
|
|
log.Infof("Delete key %s", key)
|
|
if !p.dryRun {
|
|
err := p.client.DeleteService(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
index := 0
|
|
for _, ep := range group {
|
|
if ep.RecordType != endpoint.RecordTypeTXT {
|
|
continue
|
|
}
|
|
if index >= len(services) {
|
|
prefix := ep.Labels[randomPrefixLabel]
|
|
if prefix == "" {
|
|
prefix = fmt.Sprintf("%08x", rand.Int31())
|
|
}
|
|
services = append(services, Service{
|
|
Key: p.etcdKeyFor(prefix + "." + dnsName),
|
|
TargetStrip: strings.Count(prefix, ".") + 1,
|
|
TTL: uint32(ep.RecordTTL),
|
|
})
|
|
}
|
|
services[index].Text = ep.Targets[0]
|
|
index++
|
|
}
|
|
|
|
for i := index; index > 0 && i < len(services); i++ {
|
|
services[i].Text = ""
|
|
}
|
|
|
|
for _, service := range services {
|
|
log.Infof("Add/set key %s to Host=%s, Text=%s, TTL=%d", service.Key, service.Host, service.Text, service.TTL)
|
|
if !p.dryRun {
|
|
err := p.client.SaveService(&service)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, ep := range changes.Delete {
|
|
dnsName := ep.DNSName
|
|
if ep.Labels[randomPrefixLabel] != "" {
|
|
dnsName = ep.Labels[randomPrefixLabel] + "." + dnsName
|
|
}
|
|
key := p.etcdKeyFor(dnsName)
|
|
log.Infof("Delete key %s", key)
|
|
if !p.dryRun {
|
|
err := p.client.DeleteService(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p coreDNSProvider) etcdKeyFor(dnsName string) string {
|
|
domains := strings.Split(dnsName, ".")
|
|
reverse(domains)
|
|
return p.coreDNSPrefix + strings.Join(domains, "/")
|
|
}
|
|
|
|
func guessRecordType(target string) string {
|
|
if net.ParseIP(target) != nil {
|
|
return endpoint.RecordTypeA
|
|
}
|
|
return endpoint.RecordTypeCNAME
|
|
}
|
|
|
|
func reverse(slice []string) {
|
|
for i := 0; i < len(slice)/2; i++ {
|
|
j := len(slice) - i - 1
|
|
slice[i], slice[j] = slice[j], slice[i]
|
|
}
|
|
}
|