diff --git a/cmd/devp2p/dns_route53.go b/cmd/devp2p/dns_route53.go new file mode 100644 index 0000000000..1e9b39b0ea --- /dev/null +++ b/cmd/devp2p/dns_route53.go @@ -0,0 +1,260 @@ +// 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 ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/route53" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/p2p/dnsdisc" + "gopkg.in/urfave/cli.v1" +) + +var ( + route53AccessKeyFlag = cli.StringFlag{ + Name: "access-key-id", + Usage: "AWS Access Key ID", + EnvVar: "AWS_ACCESS_KEY_ID", + } + route53AccessSecretFlag = cli.StringFlag{ + Name: "access-key-secret", + Usage: "AWS Access Key Secret", + EnvVar: "AWS_SECRET_ACCESS_KEY", + } + route53ZoneIDFlag = cli.StringFlag{ + Name: "zone-id", + Usage: "Route53 Zone ID", + } +) + +type route53Client struct { + api *route53.Route53 + zoneID string +} + +// 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(fmt.Errorf("need Route53 Access Key ID and secret proceed")) + } + config := &aws.Config{Credentials: credentials.NewStaticCredentials(akey, asec, "")} + session, err := session.NewSession(config) + if err != nil { + exit(fmt.Errorf("can't create AWS session: %v", err)) + } + return &route53Client{ + api: route53.New(session), + 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. + records := t.ToTXT(name) + changes, err := c.computeChanges(name, records) + if err != nil { + return err + } + if len(changes) == 0 { + log.Info("No DNS changes needed") + return nil + } + + // Submit change request. + log.Info(fmt.Sprintf("Submitting %d changes to Route53", len(changes))) + batch := new(route53.ChangeBatch) + batch.SetChanges(changes) + batch.SetComment(fmt.Sprintf("enrtree update of %s at seq %d", name, t.Seq())) + req := &route53.ChangeResourceRecordSetsInput{HostedZoneId: &c.zoneID, ChangeBatch: batch} + resp, err := c.api.ChangeResourceRecordSets(req) + if err != nil { + return err + } + + // Wait for the change to be applied. + log.Info(fmt.Sprintf("Waiting for change request %s", *resp.ChangeInfo.Id)) + wreq := &route53.GetChangeInput{Id: resp.ChangeInfo.Id} + return c.api.WaitUntilResourceRecordSetsChanged(wreq) +} + +// 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(&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 record. +func (c *route53Client) computeChanges(name string, records map[string]string) ([]*route53.Change, error) { + // Convert all names to lowercase. + lrecords := make(map[string]string, len(records)) + for name, r := range records { + lrecords[strings.ToLower(name)] = r + } + records = lrecords + + // Get existing records. + existing, err := c.collectRecords(name) + if err != nil { + return nil, err + } + log.Info(fmt.Sprintf("Found %d TXT records", len(existing))) + + var changes []*route53.Change + for path, val := range records { + ttl := 1 + if path != name { + ttl = 2147483647 + } + + prevRecords, exists := existing[path] + prevValue := combineTXT(prevRecords) + if !exists { + // Entry is unknown, push a new one + log.Info(fmt.Sprintf("Creating %s = %q", path, val)) + changes = append(changes, newTXTChange("CREATE", path, ttl, splitTXT(val))) + } else if prevValue != val { + // Entry already exists, only change its content. + log.Info(fmt.Sprintf("Updating %s from %q to %q", path, prevValue, val)) + changes = append(changes, newTXTChange("UPSERT", path, ttl, splitTXT(val))) + } else { + log.Info(fmt.Sprintf("Skipping %s = %q", path, val)) + } + } + + // Iterate over the old records and delete anything stale. + for path, values := range existing { + if _, ok := records[path]; ok { + continue + } + // Stale entry, nuke it. + log.Info(fmt.Sprintf("Deleting %s = %q", path, combineTXT(values))) + changes = append(changes, newTXTChange("DELETE", path, 1, values)) + } + return changes, nil +} + +// collectRecords collects all TXT records below the given name. +func (c *route53Client) collectRecords(name string) (map[string][]string, error) { + log.Info(fmt.Sprintf("Retrieving existing TXT records on %s (%s)", name, c.zoneID)) + var req route53.ListResourceRecordSetsInput + req.SetHostedZoneId(c.zoneID) + existing := make(map[string][]string) + err := c.api.ListResourceRecordSetsPages(&req, func(resp *route53.ListResourceRecordSetsOutput, last bool) bool { + for _, set := range resp.ResourceRecordSets { + if !isSubdomain(*set.Name, name) || *set.Type != "TXT" { + continue + } + name := strings.TrimSuffix(*set.Name, ".") + for _, rec := range set.ResourceRecords { + existing[name] = append(existing[name], *rec.Value) + } + } + return true + }) + return existing, err +} + +// newTXTChange creates a change to a TXT record. +func newTXTChange(action, name string, ttl int, values []string) *route53.Change { + var c route53.Change + var r route53.ResourceRecordSet + var rrs []*route53.ResourceRecord + for _, val := range values { + rr := new(route53.ResourceRecord) + rr.SetValue(val) + rrs = append(rrs, rr) + } + r.SetType("TXT") + r.SetName(name) + r.SetTTL(int64(ttl)) + r.SetResourceRecords(rrs) + c.SetAction(action) + c.SetResourceRecordSet(&r) + return &c +} + +// 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) +} + +// combineTXT concatenates the given quoted strings into a single unquoted string. +func combineTXT(values []string) string { + result := "" + for _, v := range values { + if v[0] == '"' { + v = v[1 : len(v)-1] + } + result += v + } + return result +} + +// splitTXT splits value into a list of quoted 255-character strings. +func splitTXT(value string) []string { + var result []string + for len(value) > 0 { + rlen := len(value) + if rlen > 253 { + rlen = 253 + } + result = append(result, strconv.Quote(value[:rlen])) + value = value[rlen:] + } + return result +} diff --git a/cmd/devp2p/dnscmd.go b/cmd/devp2p/dnscmd.go index f245104053..287d6e6c76 100644 --- a/cmd/devp2p/dnscmd.go +++ b/cmd/devp2p/dnscmd.go @@ -42,6 +42,7 @@ var ( dnsSignCommand, dnsTXTCommand, dnsCloudflareCommand, + dnsRoute53Command, }, } dnsSyncCommand = cli.Command{ @@ -66,11 +67,18 @@ var ( } dnsCloudflareCommand = cli.Command{ Name: "to-cloudflare", - Usage: "Deploy DNS TXT records to cloudflare", + Usage: "Deploy DNS TXT records to CloudFlare", ArgsUsage: "", Action: dnsToCloudflare, Flags: []cli.Flag{cloudflareTokenFlag, cloudflareZoneIDFlag}, } + dnsRoute53Command = cli.Command{ + Name: "to-route53", + Usage: "Deploy DNS TXT records to Amazon Route53", + ArgsUsage: "", + Action: dnsToRoute53, + Flags: []cli.Flag{route53AccessKeyFlag, route53AccessSecretFlag, route53ZoneIDFlag}, + } ) var ( @@ -194,6 +202,19 @@ func dnsToCloudflare(ctx *cli.Context) error { return client.deploy(domain, t) } +// dnsToRoute53 peforms dnsRoute53Command. +func dnsToRoute53(ctx *cli.Context) error { + if ctx.NArg() < 1 { + return fmt.Errorf("need tree definition directory as argument") + } + domain, t, err := loadTreeDefinitionForExport(ctx.Args().Get(0)) + if err != nil { + return err + } + client := newRoute53Client(ctx) + return client.deploy(domain, t) +} + // loadSigningKey loads a private key in Ethereum keystore format. func loadSigningKey(keyfile string) *ecdsa.PrivateKey { keyjson, err := ioutil.ReadFile(keyfile) diff --git a/go.mod b/go.mod index a280949e94..223086f8c5 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6 // indirect github.com/VictoriaMetrics/fastcache v1.5.3 github.com/aristanetworks/goarista v0.0.0-20170210015632-ea17b1a17847 + github.com/aws/aws-sdk-go v1.25.48 github.com/btcsuite/btcd v0.0.0-20171128150713-2e60448ffcc6 github.com/cespare/cp v0.1.0 github.com/cespare/xxhash/v2 v2.1.1 // indirect diff --git a/go.sum b/go.sum index 515207bca3..edbb5ea2e0 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= github.com/aristanetworks/goarista v0.0.0-20170210015632-ea17b1a17847 h1:rtI0fD4oG/8eVokGVPYJEW1F88p1ZNgXiEIs9thEE4A= github.com/aristanetworks/goarista v0.0.0-20170210015632-ea17b1a17847/go.mod h1:D/tb0zPVXnP7fmsLZjtdUhSsumbK/ij54UXjjVgMGxQ= +github.com/aws/aws-sdk-go v1.25.48 h1:J82DYDGZHOKHdhx6hD24Tm30c2C3GchYGfN0mf9iKUk= +github.com/aws/aws-sdk-go v1.25.48/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/btcsuite/btcd v0.0.0-20171128150713-2e60448ffcc6 h1:Eey/GGQ/E5Xp1P2Lyx1qj007hLZfbi0+CoVeJruGCtI= github.com/btcsuite/btcd v0.0.0-20171128150713-2e60448ffcc6/go.mod h1:Dmm/EzmjnCiweXmzRIAiUWCInVmPgjkzgv5k4tVyXiQ= @@ -99,6 +101,7 @@ github.com/influxdata/influxdb v1.2.3-0.20180221223340-01288bdb0883 h1:FSeK4fZCo github.com/influxdata/influxdb v1.2.3-0.20180221223340-01288bdb0883/go.mod h1:qZna6X/4elxqT3yI9iZYdZrWWdeFOOprn86kgg4+IzY= github.com/jackpal/go-nat-pmp v1.0.2-0.20160603034137-1fa385a6f458 h1:6OvNmYgJyexcZ3pYbTI9jWx5tHo1Dee/tWbLMfPe2TA= github.com/jackpal/go-nat-pmp v1.0.2-0.20160603034137-1fa385a6f458/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/julienschmidt/httprouter v1.1.1-0.20170430222011-975b5c4c7c21 h1:F/iKcka0K2LgnKy/fgSBf235AETtm1n1TvBzqu40LE0= github.com/julienschmidt/httprouter v1.1.1-0.20170430222011-975b5c4c7c21/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/karalabe/usb v0.0.0-20190919080040-51dc0efba356 h1:I/yrLt2WilKxlQKCM52clh5rGzTKpVctGT1lH4Dc8Jw=