sigs.k8s.io/external-dns@v0.14.1/registry/txt.go (about)

     1  /*
     2  Copyright 2017 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package registry
    18  
    19  import (
    20  	"context"
    21  	"errors"
    22  	"strings"
    23  	"time"
    24  
    25  	log "github.com/sirupsen/logrus"
    26  
    27  	"sigs.k8s.io/external-dns/endpoint"
    28  	"sigs.k8s.io/external-dns/plan"
    29  	"sigs.k8s.io/external-dns/provider"
    30  )
    31  
    32  const (
    33  	recordTemplate              = "%{record_type}"
    34  	providerSpecificForceUpdate = "txt/force-update"
    35  )
    36  
    37  // TXTRegistry implements registry interface with ownership implemented via associated TXT records
    38  type TXTRegistry struct {
    39  	provider provider.Provider
    40  	ownerID  string // refers to the owner id of the current instance
    41  	mapper   nameMapper
    42  
    43  	// cache the records in memory and update on an interval instead.
    44  	recordsCache            []*endpoint.Endpoint
    45  	recordsCacheRefreshTime time.Time
    46  	cacheInterval           time.Duration
    47  
    48  	// optional string to use to replace the asterisk in wildcard entries - without using this,
    49  	// registry TXT records corresponding to wildcard records will be invalid (and rejected by most providers), due to
    50  	// having a '*' appear (not as the first character) - see https://tools.ietf.org/html/rfc1034#section-4.3.3
    51  	wildcardReplacement string
    52  
    53  	managedRecordTypes []string
    54  	excludeRecordTypes []string
    55  
    56  	// encrypt text records
    57  	txtEncryptEnabled bool
    58  	txtEncryptAESKey  []byte
    59  }
    60  
    61  // NewTXTRegistry returns new TXTRegistry object
    62  func NewTXTRegistry(provider provider.Provider, txtPrefix, txtSuffix, ownerID string, cacheInterval time.Duration, txtWildcardReplacement string, managedRecordTypes, excludeRecordTypes []string, txtEncryptEnabled bool, txtEncryptAESKey []byte) (*TXTRegistry, error) {
    63  	if ownerID == "" {
    64  		return nil, errors.New("owner id cannot be empty")
    65  	}
    66  	if len(txtEncryptAESKey) == 0 {
    67  		txtEncryptAESKey = nil
    68  	} else if len(txtEncryptAESKey) != 32 {
    69  		return nil, errors.New("the AES Encryption key must have a length of 32 bytes")
    70  	}
    71  	if txtEncryptEnabled && txtEncryptAESKey == nil {
    72  		return nil, errors.New("the AES Encryption key must be set when TXT record encryption is enabled")
    73  	}
    74  
    75  	if len(txtPrefix) > 0 && len(txtSuffix) > 0 {
    76  		return nil, errors.New("txt-prefix and txt-suffix are mutual exclusive")
    77  	}
    78  
    79  	mapper := newaffixNameMapper(txtPrefix, txtSuffix, txtWildcardReplacement)
    80  
    81  	return &TXTRegistry{
    82  		provider:            provider,
    83  		ownerID:             ownerID,
    84  		mapper:              mapper,
    85  		cacheInterval:       cacheInterval,
    86  		wildcardReplacement: txtWildcardReplacement,
    87  		managedRecordTypes:  managedRecordTypes,
    88  		excludeRecordTypes:  excludeRecordTypes,
    89  		txtEncryptEnabled:   txtEncryptEnabled,
    90  		txtEncryptAESKey:    txtEncryptAESKey,
    91  	}, nil
    92  }
    93  
    94  func getSupportedTypes() []string {
    95  	return []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS}
    96  }
    97  
    98  func (im *TXTRegistry) GetDomainFilter() endpoint.DomainFilter {
    99  	return im.provider.GetDomainFilter()
   100  }
   101  
   102  func (im *TXTRegistry) OwnerID() string {
   103  	return im.ownerID
   104  }
   105  
   106  // Records returns the current records from the registry excluding TXT Records
   107  // If TXT records was created previously to indicate ownership its corresponding value
   108  // will be added to the endpoints Labels map
   109  func (im *TXTRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
   110  	// If we have the zones cached AND we have refreshed the cache since the
   111  	// last given interval, then just use the cached results.
   112  	if im.recordsCache != nil && time.Since(im.recordsCacheRefreshTime) < im.cacheInterval {
   113  		log.Debug("Using cached records.")
   114  		return im.recordsCache, nil
   115  	}
   116  
   117  	records, err := im.provider.Records(ctx)
   118  	if err != nil {
   119  		return nil, err
   120  	}
   121  
   122  	endpoints := []*endpoint.Endpoint{}
   123  
   124  	labelMap := map[endpoint.EndpointKey]endpoint.Labels{}
   125  	txtRecordsMap := map[string]struct{}{}
   126  
   127  	for _, record := range records {
   128  		if record.RecordType != endpoint.RecordTypeTXT {
   129  			endpoints = append(endpoints, record)
   130  			continue
   131  		}
   132  		// We simply assume that TXT records for the registry will always have only one target.
   133  		labels, err := endpoint.NewLabelsFromString(record.Targets[0], im.txtEncryptAESKey)
   134  		if err == endpoint.ErrInvalidHeritage {
   135  			// if no heritage is found or it is invalid
   136  			// case when value of txt record cannot be identified
   137  			// record will not be removed as it will have empty owner
   138  			endpoints = append(endpoints, record)
   139  			continue
   140  		}
   141  		if err != nil {
   142  			return nil, err
   143  		}
   144  
   145  		endpointName, recordType := im.mapper.toEndpointName(record.DNSName)
   146  		key := endpoint.EndpointKey{
   147  			DNSName:       endpointName,
   148  			RecordType:    recordType,
   149  			SetIdentifier: record.SetIdentifier,
   150  		}
   151  		labelMap[key] = labels
   152  		txtRecordsMap[record.DNSName] = struct{}{}
   153  	}
   154  
   155  	for _, ep := range endpoints {
   156  		if ep.Labels == nil {
   157  			ep.Labels = endpoint.NewLabels()
   158  		}
   159  		dnsNameSplit := strings.Split(ep.DNSName, ".")
   160  		// If specified, replace a leading asterisk in the generated txt record name with some other string
   161  		if im.wildcardReplacement != "" && dnsNameSplit[0] == "*" {
   162  			dnsNameSplit[0] = im.wildcardReplacement
   163  		}
   164  		dnsName := strings.Join(dnsNameSplit, ".")
   165  		key := endpoint.EndpointKey{
   166  			DNSName:       dnsName,
   167  			RecordType:    ep.RecordType,
   168  			SetIdentifier: ep.SetIdentifier,
   169  		}
   170  
   171  		// AWS Alias records have "new" format encoded as type "cname"
   172  		if isAlias, found := ep.GetProviderSpecificProperty("alias"); found && isAlias == "true" && ep.RecordType == endpoint.RecordTypeA {
   173  			key.RecordType = endpoint.RecordTypeCNAME
   174  		}
   175  
   176  		// Handle both new and old registry format with the preference for the new one
   177  		labels, labelsExist := labelMap[key]
   178  		if !labelsExist && ep.RecordType != endpoint.RecordTypeAAAA {
   179  			key.RecordType = ""
   180  			labels, labelsExist = labelMap[key]
   181  		}
   182  		if labelsExist {
   183  			for k, v := range labels {
   184  				ep.Labels[k] = v
   185  			}
   186  		}
   187  
   188  		// Handle the migration of TXT records created before the new format (introduced in v0.12.0).
   189  		// The migration is done for the TXT records owned by this instance only.
   190  		if len(txtRecordsMap) > 0 && ep.Labels[endpoint.OwnerLabelKey] == im.ownerID {
   191  			if plan.IsManagedRecord(ep.RecordType, im.managedRecordTypes, im.excludeRecordTypes) {
   192  				// Get desired TXT records and detect the missing ones
   193  				desiredTXTs := im.generateTXTRecord(ep)
   194  				for _, desiredTXT := range desiredTXTs {
   195  					if _, exists := txtRecordsMap[desiredTXT.DNSName]; !exists {
   196  						ep.WithProviderSpecific(providerSpecificForceUpdate, "true")
   197  					}
   198  				}
   199  			}
   200  		}
   201  	}
   202  
   203  	// Update the cache.
   204  	if im.cacheInterval > 0 {
   205  		im.recordsCache = endpoints
   206  		im.recordsCacheRefreshTime = time.Now()
   207  	}
   208  
   209  	return endpoints, nil
   210  }
   211  
   212  // generateTXTRecord generates both "old" and "new" TXT records.
   213  // Once we decide to drop old format we need to drop toTXTName() and rename toNewTXTName
   214  func (im *TXTRegistry) generateTXTRecord(r *endpoint.Endpoint) []*endpoint.Endpoint {
   215  	endpoints := make([]*endpoint.Endpoint, 0)
   216  
   217  	if !im.txtEncryptEnabled && !im.mapper.recordTypeInAffix() && r.RecordType != endpoint.RecordTypeAAAA {
   218  		// old TXT record format
   219  		txt := endpoint.NewEndpoint(im.mapper.toTXTName(r.DNSName), endpoint.RecordTypeTXT, r.Labels.Serialize(true, im.txtEncryptEnabled, im.txtEncryptAESKey))
   220  		if txt != nil {
   221  			txt.WithSetIdentifier(r.SetIdentifier)
   222  			txt.Labels[endpoint.OwnedRecordLabelKey] = r.DNSName
   223  			txt.ProviderSpecific = r.ProviderSpecific
   224  			endpoints = append(endpoints, txt)
   225  		}
   226  	}
   227  	// new TXT record format (containing record type)
   228  	recordType := r.RecordType
   229  	// AWS Alias records are encoded as type "cname"
   230  	if isAlias, found := r.GetProviderSpecificProperty("alias"); found && isAlias == "true" && recordType == endpoint.RecordTypeA {
   231  		recordType = endpoint.RecordTypeCNAME
   232  	}
   233  	txtNew := endpoint.NewEndpoint(im.mapper.toNewTXTName(r.DNSName, recordType), endpoint.RecordTypeTXT, r.Labels.Serialize(true, im.txtEncryptEnabled, im.txtEncryptAESKey))
   234  	if txtNew != nil {
   235  		txtNew.WithSetIdentifier(r.SetIdentifier)
   236  		txtNew.Labels[endpoint.OwnedRecordLabelKey] = r.DNSName
   237  		txtNew.ProviderSpecific = r.ProviderSpecific
   238  		endpoints = append(endpoints, txtNew)
   239  	}
   240  
   241  	return endpoints
   242  }
   243  
   244  // ApplyChanges updates dns provider with the changes
   245  // for each created/deleted record it will also take into account TXT records for creation/deletion
   246  func (im *TXTRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   247  	filteredChanges := &plan.Changes{
   248  		Create:    changes.Create,
   249  		UpdateNew: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateNew),
   250  		UpdateOld: endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.UpdateOld),
   251  		Delete:    endpoint.FilterEndpointsByOwnerID(im.ownerID, changes.Delete),
   252  	}
   253  	for _, r := range filteredChanges.Create {
   254  		if r.Labels == nil {
   255  			r.Labels = make(map[string]string)
   256  		}
   257  		r.Labels[endpoint.OwnerLabelKey] = im.ownerID
   258  
   259  		filteredChanges.Create = append(filteredChanges.Create, im.generateTXTRecord(r)...)
   260  
   261  		if im.cacheInterval > 0 {
   262  			im.addToCache(r)
   263  		}
   264  	}
   265  
   266  	for _, r := range filteredChanges.Delete {
   267  		// when we delete TXT records for which value has changed (due to new label) this would still work because
   268  		// !!! TXT record value is uniquely generated from the Labels of the endpoint. Hence old TXT record can be uniquely reconstructed
   269  		// !!! After migration to the new TXT registry format we can drop records in old format here!!!
   270  		filteredChanges.Delete = append(filteredChanges.Delete, im.generateTXTRecord(r)...)
   271  
   272  		if im.cacheInterval > 0 {
   273  			im.removeFromCache(r)
   274  		}
   275  	}
   276  
   277  	// make sure TXT records are consistently updated as well
   278  	for _, r := range filteredChanges.UpdateOld {
   279  		// when we updateOld TXT records for which value has changed (due to new label) this would still work because
   280  		// !!! TXT record value is uniquely generated from the Labels of the endpoint. Hence old TXT record can be uniquely reconstructed
   281  		filteredChanges.UpdateOld = append(filteredChanges.UpdateOld, im.generateTXTRecord(r)...)
   282  		// remove old version of record from cache
   283  		if im.cacheInterval > 0 {
   284  			im.removeFromCache(r)
   285  		}
   286  	}
   287  
   288  	// make sure TXT records are consistently updated as well
   289  	for _, r := range filteredChanges.UpdateNew {
   290  		filteredChanges.UpdateNew = append(filteredChanges.UpdateNew, im.generateTXTRecord(r)...)
   291  		// add new version of record to cache
   292  		if im.cacheInterval > 0 {
   293  			im.addToCache(r)
   294  		}
   295  	}
   296  
   297  	// when caching is enabled, disable the provider from using the cache
   298  	if im.cacheInterval > 0 {
   299  		ctx = context.WithValue(ctx, provider.RecordsContextKey, nil)
   300  	}
   301  	return im.provider.ApplyChanges(ctx, filteredChanges)
   302  }
   303  
   304  // AdjustEndpoints modifies the endpoints as needed by the specific provider
   305  func (im *TXTRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
   306  	return im.provider.AdjustEndpoints(endpoints)
   307  }
   308  
   309  /**
   310    nameMapper is the interface for mapping between the endpoint for the source
   311    and the endpoint for the TXT record.
   312  */
   313  
   314  type nameMapper interface {
   315  	toEndpointName(string) (endpointName string, recordType string)
   316  	toTXTName(string) string
   317  	toNewTXTName(string, string) string
   318  	recordTypeInAffix() bool
   319  }
   320  
   321  type affixNameMapper struct {
   322  	prefix              string
   323  	suffix              string
   324  	wildcardReplacement string
   325  }
   326  
   327  var _ nameMapper = affixNameMapper{}
   328  
   329  func newaffixNameMapper(prefix, suffix, wildcardReplacement string) affixNameMapper {
   330  	return affixNameMapper{prefix: strings.ToLower(prefix), suffix: strings.ToLower(suffix), wildcardReplacement: strings.ToLower(wildcardReplacement)}
   331  }
   332  
   333  // extractRecordTypeDefaultPosition extracts record type from the default position
   334  // when not using '%{record_type}' in the prefix/suffix
   335  func extractRecordTypeDefaultPosition(name string) (baseName, recordType string) {
   336  	nameS := strings.Split(name, "-")
   337  	for _, t := range getSupportedTypes() {
   338  		if nameS[0] == strings.ToLower(t) {
   339  			return strings.TrimPrefix(name, nameS[0]+"-"), t
   340  		}
   341  	}
   342  	return name, ""
   343  }
   344  
   345  // dropAffixExtractType strips TXT record to find an endpoint name it manages
   346  // it also returns the record type
   347  func (pr affixNameMapper) dropAffixExtractType(name string) (baseName, recordType string) {
   348  	prefix := pr.prefix
   349  	suffix := pr.suffix
   350  
   351  	if pr.recordTypeInAffix() {
   352  		for _, t := range getSupportedTypes() {
   353  			tLower := strings.ToLower(t)
   354  			iPrefix := strings.ReplaceAll(prefix, recordTemplate, tLower)
   355  			iSuffix := strings.ReplaceAll(suffix, recordTemplate, tLower)
   356  
   357  			if pr.isPrefix() && strings.HasPrefix(name, iPrefix) {
   358  				return strings.TrimPrefix(name, iPrefix), t
   359  			}
   360  
   361  			if pr.isSuffix() && strings.HasSuffix(name, iSuffix) {
   362  				return strings.TrimSuffix(name, iSuffix), t
   363  			}
   364  		}
   365  
   366  		// handle old TXT records
   367  		prefix = pr.dropAffixTemplate(prefix)
   368  		suffix = pr.dropAffixTemplate(suffix)
   369  	}
   370  
   371  	if pr.isPrefix() && strings.HasPrefix(name, prefix) {
   372  		return extractRecordTypeDefaultPosition(strings.TrimPrefix(name, prefix))
   373  	}
   374  
   375  	if pr.isSuffix() && strings.HasSuffix(name, suffix) {
   376  		return extractRecordTypeDefaultPosition(strings.TrimSuffix(name, suffix))
   377  	}
   378  
   379  	return "", ""
   380  }
   381  
   382  func (pr affixNameMapper) dropAffixTemplate(name string) string {
   383  	return strings.ReplaceAll(name, recordTemplate, "")
   384  }
   385  
   386  func (pr affixNameMapper) isPrefix() bool {
   387  	return len(pr.suffix) == 0
   388  }
   389  
   390  func (pr affixNameMapper) isSuffix() bool {
   391  	return len(pr.prefix) == 0 && len(pr.suffix) > 0
   392  }
   393  
   394  func (pr affixNameMapper) toEndpointName(txtDNSName string) (endpointName string, recordType string) {
   395  	lowerDNSName := strings.ToLower(txtDNSName)
   396  
   397  	// drop prefix
   398  	if pr.isPrefix() {
   399  		return pr.dropAffixExtractType(lowerDNSName)
   400  	}
   401  
   402  	// drop suffix
   403  	if pr.isSuffix() {
   404  		dc := strings.Count(pr.suffix, ".")
   405  		DNSName := strings.SplitN(lowerDNSName, ".", 2+dc)
   406  		domainWithSuffix := strings.Join(DNSName[:1+dc], ".")
   407  
   408  		r, rType := pr.dropAffixExtractType(domainWithSuffix)
   409  		return r + "." + DNSName[1+dc], rType
   410  	}
   411  	return "", ""
   412  }
   413  
   414  func (pr affixNameMapper) toTXTName(endpointDNSName string) string {
   415  	DNSName := strings.SplitN(endpointDNSName, ".", 2)
   416  
   417  	prefix := pr.dropAffixTemplate(pr.prefix)
   418  	suffix := pr.dropAffixTemplate(pr.suffix)
   419  	// If specified, replace a leading asterisk in the generated txt record name with some other string
   420  	if pr.wildcardReplacement != "" && DNSName[0] == "*" {
   421  		DNSName[0] = pr.wildcardReplacement
   422  	}
   423  
   424  	if len(DNSName) < 2 {
   425  		return prefix + DNSName[0] + suffix
   426  	}
   427  	return prefix + DNSName[0] + suffix + "." + DNSName[1]
   428  }
   429  
   430  func (pr affixNameMapper) recordTypeInAffix() bool {
   431  	if strings.Contains(pr.prefix, recordTemplate) {
   432  		return true
   433  	}
   434  	if strings.Contains(pr.suffix, recordTemplate) {
   435  		return true
   436  	}
   437  	return false
   438  }
   439  
   440  func (pr affixNameMapper) normalizeAffixTemplate(afix, recordType string) string {
   441  	if strings.Contains(afix, recordTemplate) {
   442  		return strings.ReplaceAll(afix, recordTemplate, recordType)
   443  	}
   444  	return afix
   445  }
   446  
   447  func (pr affixNameMapper) toNewTXTName(endpointDNSName, recordType string) string {
   448  	DNSName := strings.SplitN(endpointDNSName, ".", 2)
   449  	recordType = strings.ToLower(recordType)
   450  	recordT := recordType + "-"
   451  
   452  	prefix := pr.normalizeAffixTemplate(pr.prefix, recordType)
   453  	suffix := pr.normalizeAffixTemplate(pr.suffix, recordType)
   454  
   455  	// If specified, replace a leading asterisk in the generated txt record name with some other string
   456  	if pr.wildcardReplacement != "" && DNSName[0] == "*" {
   457  		DNSName[0] = pr.wildcardReplacement
   458  	}
   459  
   460  	if !pr.recordTypeInAffix() {
   461  		DNSName[0] = recordT + DNSName[0]
   462  	}
   463  
   464  	if len(DNSName) < 2 {
   465  		return prefix + DNSName[0] + suffix
   466  	}
   467  
   468  	return prefix + DNSName[0] + suffix + "." + DNSName[1]
   469  }
   470  
   471  func (im *TXTRegistry) addToCache(ep *endpoint.Endpoint) {
   472  	if im.recordsCache != nil {
   473  		im.recordsCache = append(im.recordsCache, ep)
   474  	}
   475  }
   476  
   477  func (im *TXTRegistry) removeFromCache(ep *endpoint.Endpoint) {
   478  	if im.recordsCache == nil || ep == nil {
   479  		return
   480  	}
   481  
   482  	for i, e := range im.recordsCache {
   483  		if e.DNSName == ep.DNSName && e.RecordType == ep.RecordType && e.SetIdentifier == ep.SetIdentifier && e.Targets.Same(ep.Targets) {
   484  			// We found a match delete the endpoint from the cache.
   485  			im.recordsCache = append(im.recordsCache[:i], im.recordsCache[i+1:]...)
   486  			return
   487  		}
   488  	}
   489  }