sigs.k8s.io/external-dns@v0.14.1/provider/dyn/dyn.go (about) 1 /* 2 Copyright 2018 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 dyn 18 19 import ( 20 "context" 21 "fmt" 22 "os" 23 "strconv" 24 "strings" 25 "time" 26 27 log "github.com/sirupsen/logrus" 28 29 "github.com/nesv/go-dynect/dynect" 30 31 "sigs.k8s.io/external-dns/endpoint" 32 "sigs.k8s.io/external-dns/plan" 33 "sigs.k8s.io/external-dns/provider" 34 dynsoap "sigs.k8s.io/external-dns/provider/dyn/soap" 35 ) 36 37 const ( 38 // 10 minutes default timeout if not configured using flags 39 dynDefaultTTL = 600 40 41 // when rate limit is hit retry up to 5 times after sleep 1m between retries 42 dynMaxRetriesOnErrRateLimited = 5 43 44 // two consecutive bad logins happen at least this many seconds apart 45 // While it is easy to get the username right, misconfiguring the password 46 // can get account blocked. Exit(1) is not a good solution 47 // as k8s will restart the pod and another login attempt will be made 48 badLoginMinIntervalSeconds = 30 * 60 49 50 // this prefix must be stripped from resource links before feeding them to dynect.Client.Do() 51 restAPIPrefix = "/REST/" 52 ) 53 54 func unixNow() int64 { 55 return time.Now().Unix() 56 } 57 58 // DynConfig hold connection parameters to dyn.com and internal state 59 type DynConfig struct { 60 DomainFilter endpoint.DomainFilter 61 ZoneIDFilter provider.ZoneIDFilter 62 DryRun bool 63 CustomerName string 64 Username string 65 Password string 66 MinTTLSeconds int 67 AppVersion string 68 DynVersion string 69 } 70 71 // ZoneSnapshot stores a single recordset for a zone for a single serial 72 type ZoneSnapshot struct { 73 serials map[string]int 74 endpoints map[string][]*endpoint.Endpoint 75 } 76 77 // GetRecordsForSerial retrieves from memory the last known recordset for the (zone, serial) tuple 78 func (snap *ZoneSnapshot) GetRecordsForSerial(zone string, serial int) []*endpoint.Endpoint { 79 lastSerial, ok := snap.serials[zone] 80 if !ok { 81 // no mapping 82 return nil 83 } 84 85 if lastSerial != serial { 86 // outdated mapping 87 return nil 88 } 89 90 endpoints, ok := snap.endpoints[zone] 91 if !ok { 92 // probably a bug 93 return nil 94 } 95 96 return endpoints 97 } 98 99 // StoreRecordsForSerial associates a result set with a (zone, serial) 100 func (snap *ZoneSnapshot) StoreRecordsForSerial(zone string, serial int, records []*endpoint.Endpoint) { 101 snap.serials[zone] = serial 102 snap.endpoints[zone] = records 103 } 104 105 // DynProvider is the actual interface impl. 106 type dynProviderState struct { 107 provider.BaseProvider 108 DynConfig 109 LastLoginErrorTime int64 110 111 ZoneSnapshot *ZoneSnapshot 112 } 113 114 // ZoneChange is missing from dynect: https://help.dyn.com/get-zone-changeset-api/ 115 type ZoneChange struct { 116 ID int `json:"id"` 117 UserID int `json:"user_id"` 118 Zone string `json:"zone"` 119 FQDN string `json:"FQDN"` 120 Serial int `json:"serial"` 121 TTL int `json:"ttl"` 122 Type string `json:"rdata_type"` 123 RData dynect.DataBlock `json:"rdata"` 124 } 125 126 // ZoneChangesResponse is missing from dynect: https://help.dyn.com/get-zone-changeset-api/ 127 type ZoneChangesResponse struct { 128 dynect.ResponseBlock 129 Data []ZoneChange `json:"data"` 130 } 131 132 // ZonePublishRequest is missing from dynect but the notes field is a nice place to let 133 // external-dns report some internal info during commit 134 type ZonePublishRequest struct { 135 Publish bool `json:"publish"` 136 Notes string `json:"notes"` 137 } 138 139 // ZonePublishResponse holds the status after publish 140 type ZonePublishResponse struct { 141 dynect.ResponseBlock 142 Data map[string]interface{} `json:"data"` 143 } 144 145 // NewDynProvider initializes a new Dyn Provider. 146 func NewDynProvider(config DynConfig) (provider.Provider, error) { 147 return &dynProviderState{ 148 DynConfig: config, 149 ZoneSnapshot: &ZoneSnapshot{ 150 endpoints: map[string][]*endpoint.Endpoint{}, 151 serials: map[string]int{}, 152 }, 153 }, nil 154 } 155 156 // filterAndFixLinks removes from `links` all the records we don't care about 157 // and strops the /REST/ prefix 158 func filterAndFixLinks(links []string, filter endpoint.DomainFilter) []string { 159 var result []string 160 for _, link := range links { 161 // link looks like /REST/CNAMERecord/acme.com/exchange.acme.com/349386875 162 163 // strip /REST/ 164 link = strings.TrimPrefix(link, restAPIPrefix) 165 166 // simply ignore all record types we don't care about 167 if !strings.HasPrefix(link, endpoint.RecordTypeA) && 168 !strings.HasPrefix(link, endpoint.RecordTypeCNAME) && 169 !strings.HasPrefix(link, endpoint.RecordTypeTXT) { 170 continue 171 } 172 173 // strip ID suffix 174 domain := link[0:strings.LastIndexByte(link, '/')] 175 // strip zone prefix 176 domain = domain[strings.LastIndexByte(domain, '/')+1:] 177 if filter.Match(domain) { 178 result = append(result, link) 179 } 180 } 181 182 return result 183 } 184 185 func fixMissingTTL(ttl endpoint.TTL, minTTLSeconds int) string { 186 i := dynDefaultTTL 187 if ttl.IsConfigured() { 188 if int(ttl) < minTTLSeconds { 189 i = minTTLSeconds 190 } else { 191 i = int(ttl) 192 } 193 } 194 195 return strconv.Itoa(i) 196 } 197 198 // merge produces a single list of records that can be used as a replacement. 199 // Dyn allows to replace all records with a single call 200 // Invariant: the result contains only elements from the updateNew parameter 201 func merge(updateOld, updateNew []*endpoint.Endpoint) []*endpoint.Endpoint { 202 findMatch := func(template *endpoint.Endpoint) *endpoint.Endpoint { 203 for _, new := range updateNew { 204 if template.DNSName == new.DNSName && 205 template.RecordType == new.RecordType { 206 return new 207 } 208 } 209 return nil 210 } 211 212 var result []*endpoint.Endpoint 213 for _, old := range updateOld { 214 matchingNew := findMatch(old) 215 if matchingNew == nil { 216 // no match, shouldn't happen 217 continue 218 } 219 220 if !matchingNew.Targets.Same(old.Targets) { 221 // new target: always update, TTL will be overwritten too if necessary 222 result = append(result, matchingNew) 223 continue 224 } 225 226 if matchingNew.RecordTTL != 0 && matchingNew.RecordTTL != old.RecordTTL { 227 // same target, but new non-zero TTL set in k8s, must update 228 // probably would happen only if there is a bug in the code calling the provider 229 result = append(result, matchingNew) 230 } 231 } 232 233 return result 234 } 235 236 func apiRetryLoop(f func() error) error { 237 var err error 238 for i := 0; i < dynMaxRetriesOnErrRateLimited; i++ { 239 err = f() 240 if err == nil || err != dynect.ErrRateLimited { 241 // success or not retryable error 242 return err 243 } 244 245 // https://help.dyn.com/managed-dns-api-rate-limit/ 246 log.Debugf("Rate limit has been hit, sleeping for 1m (%d/%d)", i, dynMaxRetriesOnErrRateLimited) 247 time.Sleep(1 * time.Minute) 248 } 249 250 return err 251 } 252 253 func (d *dynProviderState) allRecordsToEndpoints(records *dynsoap.GetAllRecordsResponseType) []*endpoint.Endpoint { 254 result := []*endpoint.Endpoint{} 255 // Convert each record to an endpoint 256 257 // Process A Records 258 for _, rec := range records.Data.A_records { 259 ep := &endpoint.Endpoint{ 260 DNSName: rec.Fqdn, 261 RecordTTL: endpoint.TTL(rec.Ttl), 262 RecordType: rec.Record_type, 263 Targets: endpoint.Targets{rec.Rdata.Address}, 264 } 265 log.Debugf("A record: %v", *ep) 266 result = append(result, ep) 267 } 268 269 // Process CNAME Records 270 for _, rec := range records.Data.Cname_records { 271 ep := &endpoint.Endpoint{ 272 DNSName: rec.Fqdn, 273 RecordTTL: endpoint.TTL(rec.Ttl), 274 RecordType: rec.Record_type, 275 Targets: endpoint.Targets{strings.TrimSuffix(rec.Rdata.Cname, ".")}, 276 } 277 log.Debugf("CNAME record: %v", *ep) 278 result = append(result, ep) 279 } 280 281 // Process TXT Records 282 for _, rec := range records.Data.Txt_records { 283 ep := &endpoint.Endpoint{ 284 DNSName: rec.Fqdn, 285 RecordTTL: endpoint.TTL(rec.Ttl), 286 RecordType: rec.Record_type, 287 Targets: endpoint.Targets{rec.Rdata.Txtdata}, 288 } 289 log.Debugf("TXT record: %v", *ep) 290 result = append(result, ep) 291 } 292 293 return result 294 } 295 296 func errorOrValue(err error, value interface{}) interface{} { 297 if err == nil { 298 return value 299 } 300 301 return err 302 } 303 304 // endpointToRecord puts the Target of an Endpoint in the correct field of DataBlock. 305 // See DataBlock comments for more info 306 func endpointToRecord(ep *endpoint.Endpoint) *dynect.DataBlock { 307 result := dynect.DataBlock{} 308 309 if ep.RecordType == endpoint.RecordTypeA { 310 result.Address = ep.Targets[0] 311 } else if ep.RecordType == endpoint.RecordTypeCNAME { 312 result.CName = ep.Targets[0] 313 } else if ep.RecordType == endpoint.RecordTypeTXT { 314 result.TxtData = ep.Targets[0] 315 } 316 317 return &result 318 } 319 320 func (d *dynProviderState) fetchZoneSerial(client *dynect.Client, zone string) (int, error) { 321 var resp dynect.ZoneResponse 322 323 err := client.Do("GET", fmt.Sprintf("Zone/%s", zone), nil, &resp) 324 if err != nil { 325 return 0, err 326 } 327 328 return resp.Data.Serial, nil 329 } 330 331 // Use SOAP to fetch all records with a single call 332 func (d *dynProviderState) fetchAllRecordsInZone(zone string) (*dynsoap.GetAllRecordsResponseType, error) { 333 var err error 334 335 service := dynsoap.NewDynectClient("https://api2.dynect.net/SOAP/") 336 337 sessionRequest := dynsoap.SessionLoginRequestType{ 338 Customer_name: d.CustomerName, 339 User_name: d.Username, 340 Password: d.Password, 341 Fault_incompat: 0, 342 } 343 344 var resp *dynsoap.SessionLoginResponseType 345 346 err = apiRetryLoop(func() error { 347 resp, err = service.SessionLogin(&sessionRequest) 348 return err 349 }) 350 351 if err != nil { 352 return nil, err 353 } 354 355 token := resp.Data.Token 356 357 logoutRequest := &dynsoap.SessionLogoutRequestType{ 358 Token: token, 359 Fault_incompat: 0, 360 } 361 362 defer service.SessionLogout(logoutRequest) 363 364 req := dynsoap.GetAllRecordsRequestType{ 365 Token: token, 366 Zone: zone, 367 Fault_incompat: 0, 368 } 369 370 records := &dynsoap.GetAllRecordsResponseType{} 371 372 err = apiRetryLoop(func() error { 373 records, err = service.GetAllRecords(&req) 374 return err 375 }) 376 377 if err != nil { 378 return nil, err 379 } 380 381 log.Debugf("Got all Records, status is %s", records.Status) 382 383 if strings.ToLower(records.Status) == "incomplete" { 384 jobRequest := dynsoap.GetJobRequestType{ 385 Token: token, 386 Job_id: records.Job_id, 387 Fault_incompat: 0, 388 } 389 390 jobResults := dynsoap.GetJobResponseType{} 391 err = apiRetryLoop(func() error { 392 jobResults, err := service.GetJob(&jobRequest) 393 if strings.ToLower(jobResults.Status) == "incomplete" { 394 return fmt.Errorf("job is incomplete") 395 } 396 return err 397 }) 398 399 if err != nil { 400 return nil, err 401 } 402 403 return jobResults.Data.(*dynsoap.GetAllRecordsResponseType), nil 404 } 405 406 return records, nil 407 } 408 409 // buildLinkToRecord build a resource link. The symmetry of the dyn API is used to save 410 // switch-case boilerplate. 411 // Empty response means the endpoint is not mappable to a records link: either because the fqdn 412 // is not matched by the domainFilter or it is in the wrong zone 413 func (d *dynProviderState) buildLinkToRecord(ep *endpoint.Endpoint) string { 414 if ep == nil { 415 return "" 416 } 417 matchingZone := "" 418 for _, zone := range d.ZoneIDFilter.ZoneIDs { 419 if strings.HasSuffix(ep.DNSName, zone) { 420 matchingZone = zone 421 break 422 } 423 } 424 425 if matchingZone == "" { 426 // no matching zone, ignore 427 return "" 428 } 429 430 if !d.DomainFilter.Match(ep.DNSName) { 431 // no matching domain, ignore 432 return "" 433 } 434 435 return fmt.Sprintf("%sRecord/%s/%s/", ep.RecordType, matchingZone, ep.DNSName) 436 } 437 438 // create a dynect client and performs login. You need to clean it up. 439 // This method also stores the DynAPI version. 440 // Don't user the dynect.Client.Login() 441 func (d *dynProviderState) login() (*dynect.Client, error) { 442 if d.LastLoginErrorTime != 0 { 443 secondsSinceLastError := unixNow() - d.LastLoginErrorTime 444 if secondsSinceLastError < badLoginMinIntervalSeconds { 445 return nil, fmt.Errorf("will not attempt an API call as the last login failure occurred just %ds ago", secondsSinceLastError) 446 } 447 } 448 client := dynect.NewClient(d.CustomerName) 449 450 req := dynect.LoginBlock{ 451 Username: d.Username, 452 Password: d.Password, 453 CustomerName: d.CustomerName, 454 } 455 456 var resp dynect.LoginResponse 457 458 err := client.Do("POST", "Session", req, &resp) 459 if err != nil { 460 d.LastLoginErrorTime = unixNow() 461 return nil, err 462 } 463 464 d.LastLoginErrorTime = 0 465 client.Token = resp.Data.Token 466 467 // this is the only change from the original 468 d.DynVersion = resp.Data.Version 469 return client, nil 470 } 471 472 // the zones we are allowed to touch. Currently only exact matches are considered, not all 473 // zones with the given suffix 474 func (d *dynProviderState) zones(client *dynect.Client) []string { 475 return d.ZoneIDFilter.ZoneIDs 476 } 477 478 func (d *dynProviderState) buildRecordRequest(ep *endpoint.Endpoint) (string, *dynect.RecordRequest) { 479 link := d.buildLinkToRecord(ep) 480 if link == "" { 481 return "", nil 482 } 483 484 record := dynect.RecordRequest{ 485 TTL: fixMissingTTL(ep.RecordTTL, d.MinTTLSeconds), 486 RData: *endpointToRecord(ep), 487 } 488 return link, &record 489 } 490 491 // deleteRecord deletes all existing records (CNAME, TXT, A) for the given Endpoint.DNSName with 1 API call 492 func (d *dynProviderState) deleteRecord(client *dynect.Client, ep *endpoint.Endpoint) error { 493 link := d.buildLinkToRecord(ep) 494 if link == "" { 495 return nil 496 } 497 498 response := dynect.RecordResponse{} 499 500 err := apiRetryLoop(func() error { 501 return client.Do("DELETE", link, nil, &response) 502 }) 503 504 log.Debugf("Deleting record %s: %+v,", link, errorOrValue(err, &response)) 505 return err 506 } 507 508 // replaceRecord replaces all existing records pf the given type for the Endpoint.DNSName with 1 API call 509 func (d *dynProviderState) replaceRecord(client *dynect.Client, ep *endpoint.Endpoint) error { 510 link, record := d.buildRecordRequest(ep) 511 if link == "" { 512 return nil 513 } 514 515 response := dynect.RecordResponse{} 516 err := apiRetryLoop(func() error { 517 return client.Do("PUT", link, record, &response) 518 }) 519 520 log.Debugf("Replacing record %s: %+v,", link, errorOrValue(err, &response)) 521 return err 522 } 523 524 // createRecord creates a single record with 1 API call 525 func (d *dynProviderState) createRecord(client *dynect.Client, ep *endpoint.Endpoint) error { 526 link, record := d.buildRecordRequest(ep) 527 if link == "" { 528 return nil 529 } 530 531 response := dynect.RecordResponse{} 532 err := apiRetryLoop(func() error { 533 return client.Do("POST", link, record, &response) 534 }) 535 536 log.Debugf("Creating record %s: %+v,", link, errorOrValue(err, &response)) 537 return err 538 } 539 540 // commit commits all pending changes. It will always attempt to commit, if there are no 541 func (d *dynProviderState) commit(client *dynect.Client) error { 542 errs := []error{} 543 544 for _, zone := range d.zones(client) { 545 // extra call if in debug mode to fetch pending changes 546 if log.GetLevel() >= log.DebugLevel { 547 response := ZoneChangesResponse{} 548 err := client.Do("GET", fmt.Sprintf("ZoneChanges/%s/", zone), nil, &response) 549 log.Debugf("Pending changes for zone %s: %+v", zone, errorOrValue(err, &response)) 550 } 551 552 h, err := os.Hostname() 553 if err != nil { 554 h = "unknown-host" 555 } 556 notes := fmt.Sprintf("Change by external-dns@%s, DynAPI@%s, %s on %s", 557 d.AppVersion, 558 d.DynVersion, 559 time.Now().Format(time.RFC3339), 560 h, 561 ) 562 563 zonePublish := ZonePublishRequest{ 564 Publish: true, 565 Notes: notes, 566 } 567 568 response := ZonePublishResponse{} 569 570 // always retry the commit: don't waste the good work so far 571 err = apiRetryLoop(func() error { 572 return client.Do("PUT", fmt.Sprintf("Zone/%s/", zone), &zonePublish, &response) 573 }) 574 log.Infof("Committing changes for zone %s: %+v", zone, errorOrValue(err, &response)) 575 } 576 577 switch len(errs) { 578 case 0: 579 return nil 580 case 1: 581 return errs[0] 582 default: 583 return fmt.Errorf("multiple errors committing: %+v", errs) 584 } 585 } 586 587 // Records makes on average C + 2*Z requests (Z = number of zones): 1 login + 1 fetchAllRecords 588 // A cache is used to avoid querying for every single record found. C is proportional to the number 589 // of expired/changed records 590 func (d *dynProviderState) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { 591 client, err := d.login() 592 if err != nil { 593 return nil, err 594 } 595 defer client.Logout() 596 597 log.Debugf("Using DynAPI@%s", d.DynVersion) 598 599 var result []*endpoint.Endpoint 600 601 zones := d.zones(client) 602 log.Infof("Configured zones: %+v", zones) 603 for _, zone := range zones { 604 serial, err := d.fetchZoneSerial(client, zone) 605 if err != nil { 606 if strings.Contains(err.Error(), "404 Not Found") { 607 log.Infof("Ignore zone %s as it does not exist", zone) 608 continue 609 } 610 611 return nil, err 612 } 613 614 relevantRecords := d.ZoneSnapshot.GetRecordsForSerial(zone, serial) 615 if relevantRecords != nil { 616 log.Infof("Using %d cached records for zone %s@%d", len(relevantRecords), zone, serial) 617 result = append(result, relevantRecords...) 618 continue 619 } 620 621 // Fetch All Records 622 records, err := d.fetchAllRecordsInZone(zone) 623 if err != nil { 624 return nil, err 625 } 626 relevantRecords = d.allRecordsToEndpoints(records) 627 628 log.Debugf("Relevant records %+v", relevantRecords) 629 630 d.ZoneSnapshot.StoreRecordsForSerial(zone, serial, relevantRecords) 631 log.Infof("Stored %d records for %s@%d", len(relevantRecords), zone, serial) 632 result = append(result, relevantRecords...) 633 } 634 635 return result, nil 636 } 637 638 // this method does C + 2*Z requests: C=total number of changes, Z = number of 639 // affected zones (1 login + 1 commit) 640 func (d *dynProviderState) ApplyChanges(ctx context.Context, changes *plan.Changes) error { 641 log.Debugf("Processing changes: %+v", changes) 642 643 if d.DryRun { 644 log.Infof("Will NOT delete these records: %+v", changes.Delete) 645 log.Infof("Will NOT create these records: %+v", changes.Create) 646 log.Infof("Will NOT update these records: %+v", merge(changes.UpdateOld, changes.UpdateNew)) 647 return nil 648 } 649 650 client, err := d.login() 651 if err != nil { 652 return err 653 } 654 defer client.Logout() 655 656 var errs []error 657 658 needsCommit := false 659 660 for _, ep := range changes.Delete { 661 err := d.deleteRecord(client, ep) 662 if err != nil { 663 errs = append(errs, err) 664 } else { 665 needsCommit = true 666 } 667 } 668 669 for _, ep := range changes.Create { 670 err := d.createRecord(client, ep) 671 if err != nil { 672 errs = append(errs, err) 673 } else { 674 needsCommit = true 675 } 676 } 677 678 updates := merge(changes.UpdateOld, changes.UpdateNew) 679 log.Debugf("Updates after merging: %+v", updates) 680 for _, ep := range updates { 681 err := d.replaceRecord(client, ep) 682 if err != nil { 683 errs = append(errs, err) 684 } else { 685 needsCommit = true 686 } 687 } 688 689 switch len(errs) { 690 case 0: 691 case 1: 692 return errs[0] 693 default: 694 return fmt.Errorf("multiple errors committing: %+v", errs) 695 } 696 697 if needsCommit { 698 return d.commit(client) 699 } 700 701 return nil 702 }