432 lines
12 KiB
Go
432 lines
12 KiB
Go
// 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 <http://www.gnu.org/licenses/>.
|
|
|
|
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()
|
|
}
|