dnscontrol/providers/route53/route53Provider.go

569 lines
15 KiB
Go

package route53
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
r53 "github.com/aws/aws-sdk-go/service/route53"
r53d "github.com/aws/aws-sdk-go/service/route53domains"
"github.com/StackExchange/dnscontrol/v3/models"
"github.com/StackExchange/dnscontrol/v3/pkg/diff"
"github.com/StackExchange/dnscontrol/v3/providers"
)
type route53API struct {
client *r53.Route53
registrar *r53d.Route53Domains
delegationSet *string
zones map[string]*r53.HostedZone
originalRecords []*r53.ResourceRecordSet
}
func newRoute53Reg(conf map[string]string) (providers.Registrar, error) {
return newRoute53(conf, nil)
}
func newRoute53Dsp(conf map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
return newRoute53(conf, metadata)
}
func newRoute53(m map[string]string, metadata json.RawMessage) (*route53API, error) {
keyID, secretKey, tokenID := m["KeyId"], m["SecretKey"], m["Token"]
// Route53 uses a global endpoint and route53domains
// currently only has a single regional endpoint in us-east-1
// http://docs.aws.amazon.com/general/latest/gr/rande.html#r53_region
config := &aws.Config{
Region: aws.String("us-east-1"),
}
// Token is optional and left empty unless required
if keyID != "" || secretKey != "" {
config.Credentials = credentials.NewStaticCredentials(keyID, secretKey, tokenID)
}
sess := session.Must(session.NewSession(config))
var dls *string
if val, ok := m["DelegationSet"]; ok {
fmt.Printf("ROUTE53 DelegationSet %s configured\n", val)
dls = sPtr(val)
}
api := &route53API{client: r53.New(sess), registrar: r53d.New(sess), delegationSet: dls}
err := api.getZones()
if err != nil {
return nil, err
}
return api, nil
}
var features = providers.DocumentationNotes{
providers.CanUseAlias: providers.Cannot("R53 does not provide a generic ALIAS functionality. Use R53_ALIAS instead."),
providers.DocCreateDomains: providers.Can(),
providers.DocDualHost: providers.Can(),
providers.DocOfficiallySupported: providers.Can(),
providers.CanUsePTR: providers.Can(),
providers.CanUseSRV: providers.Can(),
providers.CanUseTXTMulti: providers.Can(),
providers.CanUseCAA: providers.Can(),
providers.CanUseRoute53Alias: providers.Can(),
providers.CanGetZones: providers.Can(),
}
func init() {
providers.RegisterDomainServiceProviderType("ROUTE53", newRoute53Dsp, features)
providers.RegisterRegistrarType("ROUTE53", newRoute53Reg)
providers.RegisterCustomRecordType("R53_ALIAS", "ROUTE53", "")
}
func sPtr(s string) *string {
return &s
}
func withRetry(f func() error) {
const maxRetries = 23
// TODO: exponential backoff
const sleepTime = 5 * time.Second
var currentRetry int
for {
err := f()
if err == nil {
return
}
if strings.Contains(err.Error(), "Rate exceeded") {
currentRetry++
if currentRetry >= maxRetries {
return
}
fmt.Printf("============ Route53 rate limit exceeded. Waiting %s to retry.\n", sleepTime)
time.Sleep(sleepTime)
} else {
return
}
}
}
// ListZones lists the zones on this account.
func (r *route53API) ListZones() ([]string, error) {
var zones []string
// Assumes r.zones was filled already by newRoute53().
for i := range r.zones {
zones = append(zones, i)
}
return zones, nil
}
func (r *route53API) getZones() error {
var nextMarker *string
r.zones = make(map[string]*r53.HostedZone)
for {
var out *r53.ListHostedZonesOutput
var err error
withRetry(func() error {
inp := &r53.ListHostedZonesInput{Marker: nextMarker}
out, err = r.client.ListHostedZones(inp)
return err
})
if err != nil && strings.Contains(err.Error(), "is not authorized") {
return errors.New("check your credentials, you're not authorized to perform actions on Route 53 AWS Service")
} else if err != nil {
return err
}
for _, z := range out.HostedZones {
domain := strings.TrimSuffix(*z.Name, ".")
r.zones[domain] = z
}
if out.NextMarker != nil {
nextMarker = out.NextMarker
} else {
break
}
}
return nil
}
type errNoExist struct {
domain string
}
func (e errNoExist) Error() string {
return fmt.Sprintf("Domain %s not found in your route 53 account", e.domain)
}
func (r *route53API) GetNameservers(domain string) ([]*models.Nameserver, error) {
zone, ok := r.zones[domain]
if !ok {
return nil, errNoExist{domain}
}
var z *r53.GetHostedZoneOutput
var err error
withRetry(func() error {
z, err = r.client.GetHostedZone(&r53.GetHostedZoneInput{Id: zone.Id})
return err
})
if err != nil {
return nil, err
}
var nss []string
if z.DelegationSet != nil {
for _, nsPtr := range z.DelegationSet.NameServers {
nss = append(nss, *nsPtr)
}
}
return models.ToNameservers(nss)
}
// GetZoneRecords gets the records of a zone and returns them in RecordConfig format.
func (r *route53API) GetZoneRecords(domain string) (models.Records, error) {
zone, ok := r.zones[domain]
if !ok {
return nil, errNoExist{domain}
}
records, err := r.fetchRecordSets(zone.Id)
if err != nil {
return nil, err
}
r.originalRecords = records
var existingRecords = []*models.RecordConfig{}
for _, set := range records {
existingRecords = append(existingRecords, nativeToRecords(set, domain)...)
}
return existingRecords, nil
}
func (r *route53API) GetDomainCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
dc.Punycode()
var corrections = []*models.Correction{}
existingRecords, err := r.GetZoneRecords(dc.Name)
if err != nil {
return nil, err
}
zone, ok := r.zones[dc.Name]
if !ok {
return nil, errNoExist{dc.Name}
}
for _, want := range dc.Records {
// update zone_id to current zone.id if not specified by the user
if want.Type == "R53_ALIAS" && want.R53Alias["zone_id"] == "" {
want.R53Alias["zone_id"] = getZoneID(zone, want)
}
}
// Normalize
models.PostProcessRecords(existingRecords)
// diff
differ := diff.New(dc, getAliasMap)
namesToUpdate, err := differ.ChangedGroups(existingRecords)
if err != nil {
return nil, err
}
if len(namesToUpdate) == 0 {
return nil, nil
}
updates := map[models.RecordKey][]*models.RecordConfig{}
// for each name we need to update, collect relevant records from our desired domain state
for k := range namesToUpdate {
updates[k] = nil
for _, rc := range dc.Records {
if rc.Key() == k {
updates[k] = append(updates[k], rc)
}
}
}
// we collect all changes into one of two categories now:
// pure deletions where we delete an entire record set,
// or changes where we upsert an entire record set.
dels := []*r53.Change{}
changes := []*r53.Change{}
changeDesc := []string{}
delDesc := []string{}
for k, recs := range updates {
chg := &r53.Change{}
var rrset *r53.ResourceRecordSet
// if there are no records in our desired state for a key, then we just delete it from r53
if len(recs) == 0 {
dels = append(dels, chg)
chg.Action = sPtr("DELETE")
delDesc = append(delDesc, strings.Join(namesToUpdate[k], "\n"))
// on delete just submit the original resource set we got from r53.
for _, r := range r.originalRecords {
if unescape(r.Name) == k.NameFQDN && (*r.Type == k.Type || k.Type == "R53_ALIAS_"+*r.Type) {
rrset = r
break
}
}
if rrset == nil {
return nil, fmt.Errorf("no record set found to delete. Name: '%s'. Type: '%s'", k.NameFQDN, k.Type)
}
} else {
changes = append(changes, chg)
changeDesc = append(changeDesc, strings.Join(namesToUpdate[k], "\n"))
// on change or create, just build a new record set from our desired state
chg.Action = sPtr("UPSERT")
rrset = &r53.ResourceRecordSet{
Name: sPtr(k.NameFQDN),
Type: sPtr(k.Type),
}
for _, r := range recs {
val := r.GetTargetCombined()
if r.Type != "R53_ALIAS" {
rr := &r53.ResourceRecord{
Value: &val,
}
rrset.ResourceRecords = append(rrset.ResourceRecords, rr)
i := int64(r.TTL)
rrset.TTL = &i // TODO: make sure that ttls are consistent within a set
} else {
rrset = aliasToRRSet(zone, r)
}
}
}
chg.ResourceRecordSet = rrset
}
addCorrection := func(msg string, req *r53.ChangeResourceRecordSetsInput) {
corrections = append(corrections,
&models.Correction{
Msg: msg,
F: func() error {
var err error
req.HostedZoneId = zone.Id
withRetry(func() error {
_, err = r.client.ChangeResourceRecordSets(req)
return err
})
return err
},
})
}
getBatchSize := func(size, max int) int {
if size > max {
return max
}
return size
}
for len(dels) > 0 {
batchSize := getBatchSize(len(dels), 1000)
batch := dels[:batchSize]
dels = dels[batchSize:]
delDescBatch := delDesc[:batchSize]
delDesc = delDesc[batchSize:]
delDescBatchStr := "\n" + strings.Join(delDescBatch, "\n") + "\n"
delReq := &r53.ChangeResourceRecordSetsInput{
ChangeBatch: &r53.ChangeBatch{Changes: batch},
}
addCorrection(delDescBatchStr, delReq)
}
for len(changes) > 0 {
batchSize := getBatchSize(len(changes), 500)
batch := changes[:batchSize]
changes = changes[batchSize:]
changeDescBatch := changeDesc[:batchSize]
changeDesc = changeDesc[batchSize:]
changeDescBatchStr := "\n" + strings.Join(changeDescBatch, "\n") + "\n"
changeReq := &r53.ChangeResourceRecordSetsInput{
ChangeBatch: &r53.ChangeBatch{Changes: batch},
}
addCorrection(changeDescBatchStr, changeReq)
}
return corrections, nil
}
func nativeToRecords(set *r53.ResourceRecordSet, origin string) []*models.RecordConfig {
results := []*models.RecordConfig{}
if set.AliasTarget != nil {
rc := &models.RecordConfig{
Type: "R53_ALIAS",
TTL: 300,
R53Alias: map[string]string{
"type": *set.Type,
"zone_id": *set.AliasTarget.HostedZoneId,
},
}
rc.SetLabelFromFQDN(unescape(set.Name), origin)
rc.SetTarget(aws.StringValue(set.AliasTarget.DNSName))
results = append(results, rc)
} else if set.TrafficPolicyInstanceId != nil {
// skip traffic policy records
} else {
for _, rec := range set.ResourceRecords {
switch rtype := *set.Type; rtype {
case "SOA":
continue
case "SPF":
// route53 uses a custom record type for SPF
rtype = "TXT"
fallthrough
default:
rc := &models.RecordConfig{TTL: uint32(*set.TTL)}
rc.SetLabelFromFQDN(unescape(set.Name), origin)
if err := rc.PopulateFromString(rtype, *rec.Value, origin); err != nil {
panic(fmt.Errorf("unparsable record received from R53: %w", err))
}
results = append(results, rc)
}
}
}
return results
}
func getAliasMap(r *models.RecordConfig) map[string]string {
if r.Type != "R53_ALIAS" {
return nil
}
return r.R53Alias
}
func aliasToRRSet(zone *r53.HostedZone, r *models.RecordConfig) *r53.ResourceRecordSet {
rrset := &r53.ResourceRecordSet{
Name: sPtr(r.GetLabelFQDN()),
Type: sPtr(r.R53Alias["type"]),
}
zoneID := getZoneID(zone, r)
targetHealth := false
target := r.GetTargetField()
rrset.AliasTarget = &r53.AliasTarget{
DNSName: &target,
HostedZoneId: aws.String(zoneID),
EvaluateTargetHealth: &targetHealth,
}
return rrset
}
func getZoneID(zone *r53.HostedZone, r *models.RecordConfig) string {
zoneID := r.R53Alias["zone_id"]
if zoneID == "" {
zoneID = aws.StringValue(zone.Id)
}
if strings.HasPrefix(zoneID, "/hostedzone/") {
zoneID = strings.TrimPrefix(zoneID, "/hostedzone/")
}
return zoneID
}
func (r *route53API) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
corrections := []*models.Correction{}
actualSet, err := r.getRegistrarNameservers(&dc.Name)
if err != nil {
return nil, err
}
sort.Strings(actualSet)
actual := strings.Join(actualSet, ",")
expectedSet := []string{}
for _, ns := range dc.Nameservers {
expectedSet = append(expectedSet, ns.Name)
}
sort.Strings(expectedSet)
expected := strings.Join(expectedSet, ",")
if actual != expected {
return []*models.Correction{
{
Msg: fmt.Sprintf("Update nameservers %s -> %s", actual, expected),
F: func() error {
_, err := r.updateRegistrarNameservers(dc.Name, expectedSet)
return err
},
},
}, nil
}
return corrections, nil
}
func (r *route53API) getRegistrarNameservers(domainName *string) ([]string, error) {
var domainDetail *r53d.GetDomainDetailOutput
var err error
withRetry(func() error {
domainDetail, err = r.registrar.GetDomainDetail(&r53d.GetDomainDetailInput{DomainName: domainName})
return err
})
if err != nil {
return nil, err
}
nameservers := []string{}
for _, ns := range domainDetail.Nameservers {
nameservers = append(nameservers, *ns.Name)
}
return nameservers, nil
}
func (r *route53API) updateRegistrarNameservers(domainName string, nameservers []string) (*string, error) {
servers := []*r53d.Nameserver{}
for i := range nameservers {
servers = append(servers, &r53d.Nameserver{Name: &nameservers[i]})
}
var domainUpdate *r53d.UpdateDomainNameserversOutput
var err error
withRetry(func() error {
domainUpdate, err = r.registrar.UpdateDomainNameservers(&r53d.UpdateDomainNameserversInput{
DomainName: &domainName, Nameservers: servers})
return err
})
if err != nil {
return nil, err
}
return domainUpdate.OperationId, nil
}
func (r *route53API) fetchRecordSets(zoneID *string) ([]*r53.ResourceRecordSet, error) {
if zoneID == nil || *zoneID == "" {
return nil, nil
}
var next *string
var nextType *string
var records []*r53.ResourceRecordSet
for {
listInput := &r53.ListResourceRecordSetsInput{
HostedZoneId: zoneID,
StartRecordName: next,
StartRecordType: nextType,
MaxItems: sPtr("100"),
}
var list *r53.ListResourceRecordSetsOutput
var err error
withRetry(func() error {
list, err = r.client.ListResourceRecordSets(listInput)
return err
})
if err != nil {
return nil, err
}
records = append(records, list.ResourceRecordSets...)
if list.NextRecordName != nil {
next = list.NextRecordName
nextType = list.NextRecordType
} else {
break
}
}
return records, nil
}
// we have to process names from route53 to match what we expect and to remove their odd octal encoding
func unescape(s *string) string {
if s == nil {
return ""
}
name := strings.TrimSuffix(*s, ".")
name = strings.Replace(name, `\052`, "*", -1) // TODO: escape all octal sequences
return name
}
func (r *route53API) EnsureDomainExists(domain string) error {
if _, ok := r.zones[domain]; ok {
return nil
}
if r.delegationSet != nil {
fmt.Printf("Adding zone for %s to route 53 account with delegationSet %s\n", domain, *r.delegationSet)
} else {
fmt.Printf("Adding zone for %s to route 53 account\n", domain)
}
in := &r53.CreateHostedZoneInput{
Name: &domain,
DelegationSetId: r.delegationSet,
CallerReference: sPtr(fmt.Sprint(time.Now().UnixNano())),
}
var err error
withRetry(func() error {
_, err := r.client.CreateHostedZone(in)
return err
})
return err
}