// Copyright 2019 The go-ethereum Authors // This file is part of go-ethereum. // // go-ethereum is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // go-ethereum is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with go-ethereum. If not, see . package main import ( "context" "errors" "fmt" "slices" "strconv" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/route53" "github.com/aws/aws-sdk-go-v2/service/route53/types" "github.com/ethereum/go-ethereum/log" "github.com/ethereum/go-ethereum/p2p/dnsdisc" "github.com/urfave/cli/v2" ) const ( // Route53 limits change sets to 32k of 'RDATA size'. Change sets are also limited to // 1000 items. UPSERTs count double. // https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests-changeresourcerecordsets route53ChangeSizeLimit = 32000 route53ChangeCountLimit = 1000 maxRetryLimit = 60 ) var ( route53AccessKeyFlag = &cli.StringFlag{ Name: "access-key-id", Usage: "AWS Access Key ID", EnvVars: []string{"AWS_ACCESS_KEY_ID"}, } route53AccessSecretFlag = &cli.StringFlag{ Name: "access-key-secret", Usage: "AWS Access Key Secret", EnvVars: []string{"AWS_SECRET_ACCESS_KEY"}, } route53ZoneIDFlag = &cli.StringFlag{ Name: "zone-id", Usage: "Route53 Zone ID", } route53RegionFlag = &cli.StringFlag{ Name: "aws-region", Usage: "AWS Region", Value: "eu-central-1", } ) type route53Client struct { api *route53.Client zoneID string } type recordSet struct { values []string ttl int64 } // newRoute53Client sets up a Route53 API client from command line flags. func newRoute53Client(ctx *cli.Context) *route53Client { akey := ctx.String(route53AccessKeyFlag.Name) asec := ctx.String(route53AccessSecretFlag.Name) if akey == "" || asec == "" { exit(errors.New("need Route53 Access Key ID and secret to proceed")) } creds := aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(akey, asec, "")) cfg, err := config.LoadDefaultConfig(context.Background(), config.WithCredentialsProvider(creds)) if err != nil { exit(fmt.Errorf("can't initialize AWS configuration: %v", err)) } cfg.Region = ctx.String(route53RegionFlag.Name) return &route53Client{ api: route53.NewFromConfig(cfg), zoneID: ctx.String(route53ZoneIDFlag.Name), } } // deploy uploads the given tree to Route53. func (c *route53Client) deploy(name string, t *dnsdisc.Tree) error { if err := c.checkZone(name); err != nil { return err } // Compute DNS changes. existing, err := c.collectRecords(name) if err != nil { return err } log.Info(fmt.Sprintf("Found %d TXT records", len(existing))) records := t.ToTXT(name) changes := c.computeChanges(name, records, existing) // Submit to API. comment := fmt.Sprintf("enrtree update of %s at seq %d", name, t.Seq()) return c.submitChanges(changes, comment) } // deleteDomain removes all TXT records of the given domain. func (c *route53Client) deleteDomain(name string) error { if err := c.checkZone(name); err != nil { return err } // Compute DNS changes. existing, err := c.collectRecords(name) if err != nil { return err } log.Info(fmt.Sprintf("Found %d TXT records", len(existing))) changes := makeDeletionChanges(existing, nil) // Submit to API. comment := "enrtree delete of " + name return c.submitChanges(changes, comment) } // submitChanges submits the given DNS changes to Route53. func (c *route53Client) submitChanges(changes []types.Change, comment string) error { if len(changes) == 0 { log.Info("No DNS changes needed") return nil } var err error batches := splitChanges(changes, route53ChangeSizeLimit, route53ChangeCountLimit) changesToCheck := make([]*route53.ChangeResourceRecordSetsOutput, len(batches)) for i, changes := range batches { log.Info(fmt.Sprintf("Submitting %d changes to Route53", len(changes))) batch := &types.ChangeBatch{ Changes: changes, Comment: aws.String(fmt.Sprintf("%s (%d/%d)", comment, i+1, len(batches))), } req := &route53.ChangeResourceRecordSetsInput{HostedZoneId: &c.zoneID, ChangeBatch: batch} changesToCheck[i], err = c.api.ChangeResourceRecordSets(context.TODO(), req) if err != nil { return err } } // Wait for all change batches to propagate. for _, change := range changesToCheck { log.Info(fmt.Sprintf("Waiting for change request %s", *change.ChangeInfo.Id)) wreq := &route53.GetChangeInput{Id: change.ChangeInfo.Id} var count int for { wresp, err := c.api.GetChange(context.TODO(), wreq) if err != nil { return err } count++ if wresp.ChangeInfo.Status == types.ChangeStatusInsync || count >= maxRetryLimit { break } time.Sleep(30 * time.Second) } } return nil } // checkZone verifies zone information for the given domain. func (c *route53Client) checkZone(name string) (err error) { if c.zoneID == "" { c.zoneID, err = c.findZoneID(name) } return err } // findZoneID searches for the Zone ID containing the given domain. func (c *route53Client) findZoneID(name string) (string, error) { log.Info(fmt.Sprintf("Finding Route53 Zone ID for %s", name)) var req route53.ListHostedZonesByNameInput for { resp, err := c.api.ListHostedZonesByName(context.TODO(), &req) if err != nil { return "", err } for _, zone := range resp.HostedZones { if isSubdomain(name, *zone.Name) { return *zone.Id, nil } } if !resp.IsTruncated { break } req.DNSName = resp.NextDNSName req.HostedZoneId = resp.NextHostedZoneId } return "", errors.New("can't find zone ID for " + name) } // computeChanges creates DNS changes for the given set of DNS discovery records. // The 'existing' arg is the set of records that already exist on Route53. func (c *route53Client) computeChanges(name string, records map[string]string, existing map[string]recordSet) []types.Change { // Convert all names to lowercase. lrecords := make(map[string]string, len(records)) for name, r := range records { lrecords[strings.ToLower(name)] = r } records = lrecords var ( changes []types.Change inserts int upserts int skips int ) for path, newValue := range records { prevRecords, exists := existing[path] prevValue := strings.Join(prevRecords.values, "") // prevValue contains quoted strings, encode newValue to compare. newValue = splitTXT(newValue) // Assign TTL. ttl := int64(rootTTL) if path != name { ttl = int64(treeNodeTTL) } if !exists { // Entry is unknown, push a new one log.Debug(fmt.Sprintf("Creating %s = %s", path, newValue)) changes = append(changes, newTXTChange("CREATE", path, ttl, newValue)) inserts++ } else if prevValue != newValue || prevRecords.ttl != ttl { // Entry already exists, only change its content. log.Info(fmt.Sprintf("Updating %s from %s to %s", path, prevValue, newValue)) changes = append(changes, newTXTChange("UPSERT", path, ttl, newValue)) upserts++ } else { log.Debug(fmt.Sprintf("Skipping %s = %s", path, newValue)) skips++ } } // Iterate over the old records and delete anything stale. deletions := makeDeletionChanges(existing, records) changes = append(changes, deletions...) log.Info("Computed DNS changes", "changes", len(changes), "inserts", inserts, "skips", skips, "deleted", len(deletions), "upserts", upserts) // Ensure changes are in the correct order. sortChanges(changes) return changes } // makeDeletionChanges creates record changes which delete all records not contained in 'keep'. func makeDeletionChanges(records map[string]recordSet, keep map[string]string) []types.Change { var changes []types.Change for path, set := range records { if _, ok := keep[path]; ok { continue } log.Debug(fmt.Sprintf("Deleting %s = %s", path, strings.Join(set.values, ""))) changes = append(changes, newTXTChange("DELETE", path, set.ttl, set.values...)) } return changes } // sortChanges ensures DNS changes are in leaf-added -> root-changed -> leaf-deleted order. func sortChanges(changes []types.Change) { score := map[string]int{"CREATE": 1, "UPSERT": 2, "DELETE": 3} slices.SortFunc(changes, func(a, b types.Change) int { if a.Action == b.Action { return strings.Compare(*a.ResourceRecordSet.Name, *b.ResourceRecordSet.Name) } if score[string(a.Action)] < score[string(b.Action)] { return -1 } if score[string(a.Action)] > score[string(b.Action)] { return 1 } return 0 }) } // splitChanges splits up DNS changes such that each change batch // is smaller than the given RDATA limit. func splitChanges(changes []types.Change, sizeLimit, countLimit int) [][]types.Change { var ( batches [][]types.Change batchSize int batchCount int ) for _, ch := range changes { // Start new batch if this change pushes the current one over the limit. count := changeCount(ch) size := changeSize(ch) * count overSize := batchSize+size > sizeLimit overCount := batchCount+count > countLimit if len(batches) == 0 || overSize || overCount { batches = append(batches, nil) batchSize = 0 batchCount = 0 } batches[len(batches)-1] = append(batches[len(batches)-1], ch) batchSize += size batchCount += count } return batches } // changeSize returns the RDATA size of a DNS change. func changeSize(ch types.Change) int { size := 0 for _, rr := range ch.ResourceRecordSet.ResourceRecords { if rr.Value != nil { size += len(*rr.Value) } } return size } func changeCount(ch types.Change) int { if ch.Action == types.ChangeActionUpsert { return 2 } return 1 } // collectRecords collects all TXT records below the given name. func (c *route53Client) collectRecords(name string) (map[string]recordSet, error) { var req route53.ListResourceRecordSetsInput req.HostedZoneId = &c.zoneID existing := make(map[string]recordSet) log.Info("Loading existing TXT records", "name", name, "zone", c.zoneID) for page := 0; ; page++ { log.Debug("Loading existing TXT records", "name", name, "zone", c.zoneID, "page", page) resp, err := c.api.ListResourceRecordSets(context.TODO(), &req) if err != nil { return existing, err } for _, set := range resp.ResourceRecordSets { if !isSubdomain(*set.Name, name) || set.Type != types.RRTypeTxt { continue } s := recordSet{ttl: *set.TTL} for _, rec := range set.ResourceRecords { s.values = append(s.values, *rec.Value) } name := strings.TrimSuffix(*set.Name, ".") existing[name] = s } if !resp.IsTruncated { break } // Set the cursor to the next batch. From the AWS docs: // // To display the next page of results, get the values of NextRecordName, // NextRecordType, and NextRecordIdentifier (if any) from the response. Then submit // another ListResourceRecordSets request, and specify those values for // StartRecordName, StartRecordType, and StartRecordIdentifier. req.StartRecordIdentifier = resp.NextRecordIdentifier req.StartRecordName = resp.NextRecordName req.StartRecordType = resp.NextRecordType } log.Info("Loaded existing TXT records", "name", name, "zone", c.zoneID, "records", len(existing)) return existing, nil } // newTXTChange creates a change to a TXT record. func newTXTChange(action, name string, ttl int64, values ...string) types.Change { r := types.ResourceRecordSet{ Type: types.RRTypeTxt, Name: &name, TTL: &ttl, } var rrs []types.ResourceRecord for _, val := range values { var rr types.ResourceRecord rr.Value = aws.String(val) rrs = append(rrs, rr) } r.ResourceRecords = rrs return types.Change{ Action: types.ChangeAction(action), ResourceRecordSet: &r, } } // isSubdomain returns true if name is a subdomain of domain. func isSubdomain(name, domain string) bool { domain = strings.TrimSuffix(domain, ".") name = strings.TrimSuffix(name, ".") return strings.HasSuffix("."+name, "."+domain) } // splitTXT splits value into a list of quoted 255-character strings. func splitTXT(value string) string { var result strings.Builder for len(value) > 0 { rlen := len(value) if rlen > 253 { rlen = 253 } result.WriteString(strconv.Quote(value[:rlen])) value = value[rlen:] } return result.String() }