/* Copyright 2019 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 rdns import ( "context" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "io/ioutil" "math/rand" "os" "regexp" "strings" "time" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "go.etcd.io/etcd/clientv3" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" ) const ( etcdTimeout = 5 * time.Second rdnsMaxHosts = 10 rdnsOriginalLabel = "originalText" rdnsPrefix = "/rdnsv3" rdnsTimeout = 5 * time.Second ) func init() { rand.Seed(time.Now().UnixNano()) } // RDNSClient is an interface to work with Rancher DNS(RDNS) records in etcdv3 backend. type RDNSClient interface { Get(key string) ([]RDNSRecord, error) List(rootDomain string) ([]RDNSRecord, error) Set(value RDNSRecord) error Delete(key string) error } // RDNSConfig contains configuration to create a new Rancher DNS(RDNS) provider. type RDNSConfig struct { DryRun bool DomainFilter endpoint.DomainFilter RootDomain string } // RDNSProvider is an implementation of Provider for Rancher DNS(RDNS). type RDNSProvider struct { client RDNSClient dryRun bool domainFilter endpoint.DomainFilter rootDomain string } // RDNSRecord represents Rancher DNS(RDNS) etcdv3 record. type RDNSRecord struct { AggregationHosts []string `json:"aggregation_hosts,omitempty"` Host string `json:"host,omitempty"` Text string `json:"text,omitempty"` TTL uint32 `json:"ttl,omitempty"` Key string `json:"-"` } // RDNSRecordType represents Rancher DNS(RDNS) etcdv3 record type. type RDNSRecordType struct { Type string `json:"type,omitempty"` Domain string `json:"domain,omitempty"` } type etcdv3Client struct { client *clientv3.Client ctx context.Context } var _ RDNSClient = etcdv3Client{} // NewRDNSProvider initializes a new Rancher DNS(RDNS) based Provider. func NewRDNSProvider(config RDNSConfig) (*RDNSProvider, error) { client, err := newEtcdv3Client() if err != nil { return nil, err } domain := os.Getenv("RDNS_ROOT_DOMAIN") if domain == "" { return nil, errors.New("needed root domain environment") } return &RDNSProvider{ client: client, dryRun: config.DryRun, domainFilter: config.DomainFilter, rootDomain: domain, }, nil } // Records returns all DNS records found in Rancher DNS(RDNS) etcdv3 backend. Depending on the record fields // it may be mapped to one or two records of type A, TXT, A+TXT. func (p RDNSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { var result []*endpoint.Endpoint rs, err := p.client.List(p.rootDomain) if err != nil { return nil, err } for _, r := range rs { domains := strings.Split(strings.TrimPrefix(r.Key, rdnsPrefix+"/"), "/") keyToDNSNameSplits(domains) dnsName := strings.Join(domains, ".") if !p.domainFilter.Match(dnsName) { continue } // only return rdnsMaxHosts at most if len(r.AggregationHosts) > 0 { if len(r.AggregationHosts) > rdnsMaxHosts { r.AggregationHosts = r.AggregationHosts[:rdnsMaxHosts] } ep := endpoint.NewEndpointWithTTL( dnsName, endpoint.RecordTypeA, endpoint.TTL(r.TTL), r.AggregationHosts..., ) ep.Labels[rdnsOriginalLabel] = r.Text result = append(result, ep) } if r.Text != "" { ep := endpoint.NewEndpoint( dnsName, endpoint.RecordTypeTXT, r.Text, ) result = append(result, ep) } } return result, nil } // ApplyChanges stores changes back to etcdv3 converting them to Rancher DNS(RDNS) format and aggregating A and TXT records. func (p RDNSProvider) 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 _, ep := range changes.UpdateNew { if ep.RecordType == endpoint.RecordTypeA { // append useless domain records to the changes.Delete if err := p.filterAndRemoveUseless(ep, changes); err != nil { return err } } 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 rs []RDNSRecord for _, ep := range group { if ep.RecordType == endpoint.RecordTypeTXT { continue } for _, target := range ep.Targets { rs = append(rs, RDNSRecord{ Host: target, Text: ep.Labels[rdnsOriginalLabel], Key: keyFor(ep.DNSName) + "/" + formatKey(target), TTL: uint32(ep.RecordTTL), }) } } // Add the TXT attribute to the existing A record for _, ep := range group { if ep.RecordType != endpoint.RecordTypeTXT { continue } for i, r := range rs { if strings.Contains(r.Key, keyFor(ep.DNSName)) { r.Text = ep.Targets[0] rs[i] = r } } } for _, r := range rs { log.Infof("Add/set key %s to Host=%s, Text=%s, TTL=%d", r.Key, r.Host, r.Text, r.TTL) if !p.dryRun { err := p.client.Set(r) if err != nil { return err } } } } for _, ep := range changes.Delete { key := keyFor(ep.DNSName) log.Infof("Delete key %s", key) if !p.dryRun { err := p.client.Delete(key) if err != nil { return err } } } return nil } // filterAndRemoveUseless filter and remove useless records. func (p *RDNSProvider) filterAndRemoveUseless(ep *endpoint.Endpoint, changes *plan.Changes) error { rs, err := p.client.Get(keyFor(ep.DNSName)) if err != nil { return err } for _, r := range rs { exist := false for _, target := range ep.Targets { if strings.Contains(r.Key, formatKey(target)) { exist = true continue } } if !exist { ds := strings.Split(strings.TrimPrefix(r.Key, rdnsPrefix+"/"), "/") keyToDNSNameSplits(ds) changes.Delete = append(changes.Delete, &endpoint.Endpoint{ DNSName: strings.Join(ds, "."), }) } } return nil } // newEtcdv3Client is an etcdv3 client constructor. func newEtcdv3Client() (RDNSClient, error) { cfg := &clientv3.Config{} endpoints := os.Getenv("ETCD_URLS") ca := os.Getenv("ETCD_CA_FILE") cert := os.Getenv("ETCD_CERT_FILE") key := os.Getenv("ETCD_KEY_FILE") name := os.Getenv("ETCD_TLS_SERVER_NAME") insecure := os.Getenv("ETCD_TLS_INSECURE") if endpoints == "" { endpoints = "http://localhost:2379" } urls := strings.Split(endpoints, ",") scheme := strings.ToLower(urls[0])[0:strings.Index(strings.ToLower(urls[0]), "://")] switch scheme { case "http": cfg.Endpoints = urls case "https": var certificates []tls.Certificate insecure = strings.ToLower(insecure) isInsecure := insecure == "true" || insecure == "yes" || insecure == "1" if ca != "" && key == "" || cert == "" && key != "" { return nil, errors.New("either both cert and key or none must be provided") } if cert != "" { cert, err := tls.LoadX509KeyPair(cert, key) if err != nil { return nil, fmt.Errorf("could not load TLS cert: %s", err) } certificates = append(certificates, cert) } config := &tls.Config{ Certificates: certificates, InsecureSkipVerify: isInsecure, ServerName: name, } if ca != "" { roots := x509.NewCertPool() pem, err := ioutil.ReadFile(ca) if err != nil { return nil, fmt.Errorf("error reading %s: %s", ca, err) } ok := roots.AppendCertsFromPEM(pem) if !ok { return nil, fmt.Errorf("could not read root certs: %s", err) } config.RootCAs = roots } cfg.Endpoints = urls cfg.TLS = config default: return nil, errors.New("etcdv3 URLs must start with either http:// or https://") } c, err := clientv3.New(*cfg) if err != nil { return nil, err } return etcdv3Client{c, context.Background()}, nil } // Get return A records stored in etcdv3 stored anywhere under the given key (recursively). func (c etcdv3Client) Get(key string) ([]RDNSRecord, error) { ctx, cancel := context.WithTimeout(c.ctx, rdnsTimeout) defer cancel() result, err := c.client.Get(ctx, key, clientv3.WithPrefix()) if err != nil { return nil, err } rs := make([]RDNSRecord, 0) for _, v := range result.Kvs { r := new(RDNSRecord) if err := json.Unmarshal(v.Value, r); err != nil { return nil, fmt.Errorf("%s: %s", v.Key, err.Error()) } r.Key = string(v.Key) rs = append(rs, *r) } return rs, nil } // List return all records stored in etcdv3 stored anywhere under the given rootDomain (recursively). func (c etcdv3Client) List(rootDomain string) ([]RDNSRecord, error) { ctx, cancel := context.WithTimeout(c.ctx, rdnsTimeout) defer cancel() path := keyFor(rootDomain) result, err := c.client.Get(ctx, path, clientv3.WithPrefix()) if err != nil { return nil, err } return c.aggregationRecords(result) } // Set persists records data into etcdv3. func (c etcdv3Client) Set(r RDNSRecord) error { ctx, cancel := context.WithTimeout(c.ctx, etcdTimeout) defer cancel() v, err := json.Marshal(&r) if err != nil { return err } if r.Text == "" && r.Host == "" { return nil } _, err = c.client.Put(ctx, r.Key, string(v)) if err != nil { return err } return nil } // Delete deletes record from etcdv3. func (c etcdv3Client) Delete(key string) error { ctx, cancel := context.WithTimeout(c.ctx, etcdTimeout) defer cancel() _, err := c.client.Delete(ctx, key, clientv3.WithPrefix()) return err } // aggregationRecords will aggregation multi A records under the given path. // e.g. A: 1_1_1_1.xxx.lb.rancher.cloud & 2_2_2_2.sample.lb.rancher.cloud => sample.lb.rancher.cloud {"aggregation_hosts": ["1.1.1.1", "2.2.2.2"]} // e.g. TXT: sample.lb.rancher.cloud => sample.lb.rancher.cloud => {"text": "xxx"} func (c etcdv3Client) aggregationRecords(result *clientv3.GetResponse) ([]RDNSRecord, error) { var rs []RDNSRecord bx := make(map[RDNSRecordType]RDNSRecord) for _, n := range result.Kvs { r := new(RDNSRecord) if err := json.Unmarshal(n.Value, r); err != nil { return nil, fmt.Errorf("%s: %s", n.Key, err.Error()) } r.Key = string(n.Key) if r.Host == "" && r.Text == "" { continue } if r.Host != "" { c := RDNSRecord{ AggregationHosts: r.AggregationHosts, Host: r.Host, Text: r.Text, TTL: r.TTL, Key: r.Key, } n, isContinue := appendRecords(c, endpoint.RecordTypeA, bx, rs) if isContinue { continue } rs = n } if r.Text != "" && r.Host == "" { c := RDNSRecord{ AggregationHosts: []string{}, Host: r.Host, Text: r.Text, TTL: r.TTL, Key: r.Key, } n, isContinue := appendRecords(c, endpoint.RecordTypeTXT, bx, rs) if isContinue { continue } rs = n } } return rs, nil } // appendRecords append record to an array func appendRecords(r RDNSRecord, dnsType string, bx map[RDNSRecordType]RDNSRecord, rs []RDNSRecord) ([]RDNSRecord, bool) { dnsName := keyToParentDNSName(r.Key) bt := RDNSRecordType{Domain: dnsName, Type: dnsType} if v, ok := bx[bt]; ok { // skip the TXT records if already added to record list. // append A record if dnsName already added to record list but not found the value. // the same record might be found in multiple etcdv3 nodes. if bt.Type == endpoint.RecordTypeA { exist := false for _, h := range v.AggregationHosts { if h == r.Host { exist = true break } } if !exist { for i, t := range rs { if !strings.HasPrefix(r.Key, t.Key) { continue } t.Host = "" t.AggregationHosts = append(t.AggregationHosts, r.Host) bx[bt] = t rs[i] = t } } } return rs, true } if bt.Type == endpoint.RecordTypeA { r.AggregationHosts = append(r.AggregationHosts, r.Host) } r.Key = rdnsPrefix + dnsNameToKey(dnsName) r.Host = "" bx[bt] = r rs = append(rs, r) return rs, false } // keyFor used to get a path as etcdv3 preferred. // e.g. sample.lb.rancher.cloud => /rdnsv3/cloud/rancher/lb/sample func keyFor(fqdn string) string { return rdnsPrefix + dnsNameToKey(fqdn) } // keyToParentDNSName used to get dnsName. // e.g. /rdnsv3/cloud/rancher/lb/sample/xxx => xxx.sample.lb.rancher.cloud // e.g. /rdnsv3/cloud/rancher/lb/sample/xxx/1_1_1_1 => xxx.sample.lb.rancher.cloud func keyToParentDNSName(key string) string { ds := strings.Split(strings.TrimPrefix(key, rdnsPrefix+"/"), "/") keyToDNSNameSplits(ds) dns := strings.Join(ds, ".") prefix := strings.Split(dns, ".")[0] p := `^\d{1,3}_\d{1,3}_\d{1,3}_\d{1,3}$` m, _ := regexp.MatchString(p, prefix) if prefix != "" && strings.Contains(prefix, "_") && m { // 1_1_1_1.xxx.sample.lb.rancher.cloud => xxx.sample.lb.rancher.cloud return strings.Join(strings.Split(dns, ".")[1:], ".") } return dns } // dnsNameToKey used to convert domain to a path as etcdv3 preferred. // e.g. sample.lb.rancher.cloud => /cloud/rancher/lb/sample func dnsNameToKey(domain string) string { ss := strings.Split(domain, ".") last := len(ss) - 1 for i := 0; i < len(ss)/2; i++ { ss[i], ss[last-i] = ss[last-i], ss[i] } return "/" + strings.Join(ss, "/") } // keyToDNSNameSplits used to reverse etcdv3 path to domain splits. // e.g. /cloud/rancher/lb/sample => [sample lb rancher cloud] func keyToDNSNameSplits(ss []string) { for i := 0; i < len(ss)/2; i++ { j := len(ss) - i - 1 ss[i], ss[j] = ss[j], ss[i] } } // formatKey used to format a key as etcdv3 preferred // e.g. 1.1.1.1 => 1_1_1_1 // e.g. sample.lb.rancher.cloud => sample_lb_rancher_cloud func formatKey(key string) string { return strings.Replace(key, ".", "_", -1) }