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 }