diff --git a/cmd/algorelay/commands.go b/cmd/algorelay/commands.go
new file mode 100644
index 0000000000..dad5cadf14
--- /dev/null
+++ b/cmd/algorelay/commands.go
@@ -0,0 +1,45 @@
+// Copyright (C) 2019 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func init() {
+}
+
+var rootCmd = &cobra.Command{
+ Use: "algorelay",
+ Short: "algorelay",
+ Long: ``,
+ Run: func(cmd *cobra.Command, args []string) {
+ // If no arguments passed, we should fallback to help
+
+ cmd.HelpFunc()(cmd, args)
+ },
+}
+
+func main() {
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Println(err)
+ os.Exit(1)
+ }
+}
diff --git a/cmd/algorelay/eb/eb.go b/cmd/algorelay/eb/eb.go
new file mode 100644
index 0000000000..283ebc38ea
--- /dev/null
+++ b/cmd/algorelay/eb/eb.go
@@ -0,0 +1,26 @@
+// Copyright (C) 2019 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package eb
+
+// Relay represents the configuration data necessary for a single Relay
+type Relay struct {
+ ID int64 // db key injected when loaded
+ IPOrDNSName string
+ MetricsEnabled bool
+ CheckSuccess bool // true if check was successful
+ DNSAlias string // DNS Alias name used
+}
diff --git a/cmd/algorelay/relayCmd.go b/cmd/algorelay/relayCmd.go
new file mode 100644
index 0000000000..6e6fa6eaf9
--- /dev/null
+++ b/cmd/algorelay/relayCmd.go
@@ -0,0 +1,511 @@
+// Copyright (C) 2019 Algorand, Inc.
+// This file is part of go-algorand
+//
+// go-algorand is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// go-algorand 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 Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with go-algorand. If not, see .
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/algorand/go-algorand/cmd/algorelay/eb"
+ "github.com/algorand/go-algorand/tools/network/cloudflare"
+ "github.com/algorand/go-algorand/util/codecs"
+)
+
+var (
+ inputFileArg string
+ outputFileArg string
+ srvDomainArg string
+ nameDomainArg string
+ defaultPortArg uint16
+ dnsBootstrapArg string
+ recordIDArg int64
+
+ cfEmail string
+ cfAuthKey string
+ cfSrvZoneID string
+ cfNameZoneID string
+)
+
+var nameRecordTypes = []string{"A", "CNAME", "SRV"}
+var srvRecordTypes = []string{"SRV"}
+
+const metricsPort = uint16(9100)
+
+func init() {
+ cfSrvZoneID = os.Getenv("CLOUDFLARE_SRV_ZONE_ID")
+ cfNameZoneID = os.Getenv("CLOUDFLARE_NAME_ZONE_ID")
+ cfEmail = os.Getenv("CLOUDFLARE_EMAIL")
+ cfAuthKey = os.Getenv("CLOUDFLARE_AUTH_KEY")
+ if cfSrvZoneID == "" || cfNameZoneID == "" || cfEmail == "" || cfAuthKey == "" {
+ panic("One or more credentials missing from ENV")
+ }
+
+ rootCmd.AddCommand(checkCmd)
+
+ checkCmd.Flags().StringVarP(&inputFileArg, "inputfile", "i", "", "File containing Relay data")
+ checkCmd.MarkFlagRequired("inputfile")
+
+ checkCmd.Flags().StringVarP(&outputFileArg, "outputfile", "o", "", "File to output results to, as JSON")
+
+ checkCmd.Flags().Int64Var(&recordIDArg, "id", 0, "Specific Datastore record ID to check (all if not specified)")
+
+ checkCmd.Flags().StringVarP(&srvDomainArg, "srvdomain", "s", "", "Domain name for SRV records")
+ checkCmd.MarkFlagRequired("srvdomain")
+ checkCmd.Flags().StringVarP(&nameDomainArg, "namedomain", "n", "", "Domain name for A/CNAME records")
+ checkCmd.MarkFlagRequired("namedomain")
+ checkCmd.Flags().Uint16VarP(&defaultPortArg, "defaultport", "p", 4160, "Default listening port (eg 4160)")
+ checkCmd.MarkFlagRequired("defaultport")
+ checkCmd.Flags().StringVarP(&dnsBootstrapArg, "dnsbootstrap", "b", "", "Bootstrap name for SRV records (eg mainnet)")
+ checkCmd.MarkFlagRequired("dnsbootstrap")
+
+
+ rootCmd.AddCommand(updateCmd)
+
+ updateCmd.Flags().StringVarP(&inputFileArg, "inputfile", "i", "", "File containing Relay data")
+ updateCmd.MarkFlagRequired("inputfile")
+
+ updateCmd.Flags().StringVarP(&outputFileArg, "outputfile", "o", "", "File to output results to, as JSON")
+
+ updateCmd.Flags().Int64Var(&recordIDArg, "id", 0, "Specific Datastore record ID to check (all if not specified)")
+
+ updateCmd.Flags().StringVarP(&srvDomainArg, "srvdomain", "s", "", "Domain name for SRV records")
+ updateCmd.MarkFlagRequired("srvdomain")
+ updateCmd.Flags().StringVarP(&nameDomainArg, "namedomain", "n", "", "Domain name for A/CNAME records")
+ updateCmd.MarkFlagRequired("namedomain")
+ updateCmd.Flags().Uint16VarP(&defaultPortArg, "defaultport", "p", 4160, "Default listening port (eg 4160)")
+ updateCmd.MarkFlagRequired("defaultport")
+ updateCmd.Flags().StringVarP(&dnsBootstrapArg, "dnsbootstrap", "b", "", "Bootstrap name for SRV records (eg mainnet)")
+ updateCmd.MarkFlagRequired("dnsbootstrap")
+}
+
+func loadRelays(file string) []eb.Relay {
+ var relays []eb.Relay
+ err := codecs.LoadObjectFromFile(file, &relays)
+ if err != nil {
+ panic(err)
+ }
+ return relays
+}
+
+type checkResult struct {
+ ID int64
+ Success bool
+ Error string `json:",omitempty"`
+}
+
+type dnsContext struct {
+ nameEntries map[string]string
+ bootstrap srvService
+ metrics srvService
+}
+
+type srvService struct {
+ serviceName string
+ entries map[string]uint16
+ shortName string
+ networkName string
+}
+
+func makeDNSContext() *dnsContext {
+ nameEntries, err := getReverseMappedEntries(cfNameZoneID, nameRecordTypes)
+ if err != nil {
+ panic(err)
+ }
+
+ bootstrap, err := getSrvRecords("_algobootstrap", dnsBootstrapArg + "." + srvDomainArg, cfSrvZoneID)
+ if err != nil {
+ panic(err)
+ }
+
+ metrics, err := getSrvRecords("_metrics", srvDomainArg, cfSrvZoneID)
+ if err != nil {
+ panic(err)
+ }
+
+ return &dnsContext{
+ nameEntries: nameEntries,
+ bootstrap: bootstrap,
+ metrics: metrics,
+ }
+}
+
+func makeService(shortName, networkName string) srvService {
+ return srvService{
+ serviceName: shortName + "._tcp." + networkName,
+ entries: make(map[string]uint16),
+ shortName: shortName,
+ networkName: networkName,
+ }
+}
+
+var checkCmd = &cobra.Command{
+ Use: "check",
+ Short: "Check status of all relays",
+ Run: func(cmd *cobra.Command, args []string) {
+ relays := loadRelays(inputFileArg)
+
+ context := makeDNSContext()
+
+ checkOne := recordIDArg != 0
+ results := make([]checkResult,0)
+ anyCheckError := false
+
+ for _, relay := range relays {
+ if checkOne && relay.ID != recordIDArg {
+ continue
+ }
+
+ if !relay.CheckSuccess {
+ continue
+ }
+
+ const checkOnly = true
+ name, port, err := ensureRelayStatus(checkOnly, relay, nameDomainArg, srvDomainArg, defaultPortArg, context)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "[%d] ERROR: %s: %s\n", relay.ID, relay.IPOrDNSName, err)
+ results = append(results, checkResult{
+ ID: relay.ID,
+ Success: false,
+ Error: err.Error(),
+ })
+ anyCheckError = true
+ } else {
+ fmt.Printf("[%d] OK: %s -> %s:%d\n", relay.ID, relay.IPOrDNSName, name, port)
+ results = append(results, checkResult{
+ ID: relay.ID,
+ Success: true,
+ })
+ }
+
+ if checkOne {
+ break
+ }
+ }
+
+ if outputFileArg != "" {
+ codecs.SaveObjectToFile(outputFileArg, &results, true)
+ }
+
+ // Only return success if all checked out
+ if anyCheckError {
+ os.Exit(-1)
+ }
+ },
+}
+
+var updateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Updates configuration for all relays to match the expectations",
+ Run: func(cmd *cobra.Command, args []string) {
+ relays := loadRelays(inputFileArg)
+
+ context := makeDNSContext()
+
+ updateOne := recordIDArg != 0
+ results := make([]checkResult,0)
+ anyUpdateError := false
+
+ for _, relay := range relays {
+ if updateOne && relay.ID != recordIDArg {
+ continue
+ }
+
+ if !relay.CheckSuccess {
+ fmt.Printf("[%d] OK: Skipping NotSuccessful %s\n", relay.ID, relay.IPOrDNSName)
+ // Don't output results if skipped
+ continue
+ }
+ const checkOnly = false
+ name, port, err := ensureRelayStatus(checkOnly, relay, nameDomainArg, srvDomainArg, defaultPortArg, context)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "[%d] ERROR: %s: %s\n", relay.ID, relay.IPOrDNSName, err)
+ results = append(results, checkResult{
+ ID: relay.ID,
+ Success: false,
+ Error: err.Error(),
+ })
+ anyUpdateError = true
+ } else {
+ fmt.Printf("[%d] OK: %s -> %s:%d\n", relay.ID, relay.IPOrDNSName, name, port)
+ results = append(results, checkResult{
+ ID: relay.ID,
+ Success: true,
+ })
+ }
+
+ if updateOne {
+ break
+ }
+ }
+
+ if outputFileArg != "" {
+ codecs.SaveObjectToFile(outputFileArg, &results, true)
+ }
+
+ // Only return success if all checked out
+ if anyUpdateError {
+ os.Exit(-1)
+ }
+ },
+}
+
+func ensureRelayStatus(checkOnly bool, relay eb.Relay, nameDomain string, srvDomain string, defaultPort uint16, ctx *dnsContext) (srvName string, srvPort uint16, err error) {
+ var port uint16
+ target, portString, err := net.SplitHostPort(relay.IPOrDNSName)
+ if err != nil {
+ target = relay.IPOrDNSName
+ port = defaultPort
+ } else {
+ var port64 uint64
+ port64, err = strconv.ParseUint(portString, 10, 16)
+ if err != nil {
+ return
+ }
+ port = uint16(port64)
+ }
+
+ if port == 0 {
+ err = fmt.Errorf("%s - port cannot be zero", relay.IPOrDNSName)
+ return
+ }
+
+ // Error if target has another name entry - target should be relay provider's domain so shouldn't be possible
+ if mapsTo, has := ctx.nameEntries[target]; has {
+ err = fmt.Errorf("relay target has a DNS Name entry and should not (%s -> %s)", target, mapsTo)
+ }
+
+ names, err := getTargetDNSChain(ctx.nameEntries, target)
+ if err != nil {
+ return
+ }
+
+ // Error if no entries
+ if len(names) == 1 {
+ if checkOnly {
+ err = fmt.Errorf("no DNS entries found mapping to %s in '%s'", target, nameDomain)
+ return
+ }
+ }
+
+ topmost := names[len(names)-1]
+
+ if relay.DNSAlias == "" {
+ err = fmt.Errorf("missing DNSAlias name")
+ }
+
+ targetDomainAlias := relay.DNSAlias + "." + nameDomain
+ if topmost != targetDomainAlias {
+ if checkOnly {
+ err = fmt.Errorf("topmost DNS name is not the assigned DNS Alias (wanted: %s, found %s)",
+ relay.DNSAlias, topmost)
+ return
+ }
+
+ // Add A/CNAME for the DNSAlias assigned
+ err = addDNSRecord(targetDomainAlias, topmost, cfNameZoneID)
+ if err != nil {
+ return
+ }
+ fmt.Printf("[%d] Added DNS Record: %s -> %s\n", relay.ID, targetDomainAlias, topmost)
+
+ // Update our state
+ names = append(names, targetDomainAlias)
+ topmost = targetDomainAlias
+ }
+
+ var ensureEntry = func(use string, entries map[string]uint16, port uint16) error {
+ type srvMatch struct {
+ name string
+ port uint16
+ }
+
+ // Now check for SRV entries for anything in that chain
+ var matches []srvMatch
+ for _, name := range names {
+ entry, has := entries[name]
+ if has {
+ matches = append(matches, srvMatch{name, entry})
+ }
+ }
+
+ if len(matches) == 0 {
+ return fmt.Errorf("no %s SRV entries found mapping to %s in '%s'", use, target, srvDomain)
+ }
+
+ if len(matches) > 1 {
+ return fmt.Errorf("multiple %s SRV entries found in the chain mapping to %s", use, target)
+ }
+
+ if matches[0].name != topmost || matches[0].port != port {
+ return fmt.Errorf("existing %s SRV record mapped to intermediate DNS name or wrong port (wanted %s:%d, found %s:%d)",
+ use, topmost, port, matches[0].name, matches[0].port)
+ }
+ return nil
+ }
+
+ err = ensureEntry("bootstrap", ctx.bootstrap.entries, port)
+ if err != nil {
+ if checkOnly {
+ return
+ }
+
+ // Add SRV entry to map to our DNSAlias
+ err = addSRVRecord(ctx.bootstrap.networkName, topmost, port, ctx.bootstrap.shortName, cfSrvZoneID)
+ if err != nil {
+ return
+ }
+ fmt.Printf("[%d] Added boostrap SRV Record: %s:%d\n", relay.ID, targetDomainAlias, port)
+ }
+
+ err = ensureEntry("metrics", ctx.metrics.entries, metricsPort)
+ if relay.MetricsEnabled {
+ if err != nil {
+ if checkOnly {
+ return
+ }
+
+ // Add SRV entry for metrics
+ err = addSRVRecord(ctx.metrics.networkName, topmost, metricsPort, ctx.metrics.shortName, cfSrvZoneID)
+ if err != nil {
+ return
+ }
+ fmt.Printf("[%d] Added metrics SRV Record: %s:%d\n", relay.ID, targetDomainAlias, metricsPort)
+ }
+ } else if err == nil {
+ err = fmt.Errorf("metrics should not be registered for %s but it is", target)
+ } else {
+ // If metrics are not enabled, then we SHOULD get an error.
+ // Since this isn't actually an error, reset to nil
+ err = nil
+ }
+
+ srvName = topmost
+ srvPort = port
+ return
+}
+
+// Returns an array of names starting with the target ip/name and ending with the outermost reference
+func getTargetDNSChain(nameEntries map[string]string, target string) (names []string, err error) {
+ target = strings.ToLower(target)
+ if err != nil {
+ return
+ }
+
+ names = append(names, target)
+ for {
+ from, has := nameEntries[target]
+ if !has {
+ return
+ }
+ names = append(names, from)
+ target = from
+ }
+}
+
+func getReverseMappedEntries(zoneID string, recordTypes []string) (reverseMap map[string]string, err error) {
+ reverseMap = make(map[string]string)
+
+ cloudflareDNS := cloudflare.NewDNS(zoneID, cfEmail, cfAuthKey)
+
+ for _, recType := range recordTypes {
+ var records []cloudflare.DNSRecordResponseEntry
+ records, err = cloudflareDNS.ListDNSRecord(context.Background(), recType, "", "", "", "", "")
+ if err != nil {
+ return
+ }
+
+ for _, record := range records {
+ // Error if duplicates found
+ from := strings.ToLower(record.Name)
+ target := strings.ToLower(record.Content)
+ if existing, has := reverseMap[target]; has {
+ err = fmt.Errorf("duplicate NAME entries mapped to %s: (%s && %s)", target, from, existing)
+ return
+ }
+ reverseMap[target] = from
+ }
+ }
+ return
+}
+
+func getSrvRecords(serviceName string, networkName, zoneID string) (service srvService, err error){
+ service = makeService(serviceName, networkName)
+
+ cloudflareDNS := cloudflare.NewDNS(zoneID, cfEmail, cfAuthKey)
+
+ var records []cloudflare.DNSRecordResponseEntry
+ records, err = cloudflareDNS.ListDNSRecord(context.Background(), "SRV", service.serviceName, "", "", "", "")
+ if err != nil {
+ return
+ }
+
+ for _, record := range records {
+ // record.Content is "priority port dnsname"
+ contents := strings.Split(record.Content, "\t")
+ target := strings.ToLower(contents[2])
+ target = strings.TrimRight(target, ".")
+ portString := contents[1]
+ var port64 uint64
+ port64, err = strconv.ParseUint(portString, 10, 16)
+ if err != nil {
+ panic(fmt.Sprintf("Invalid SRV Port for %s: %s", target, portString))
+ }
+ port := uint16(port64)
+
+ // Error if duplicates found
+ if existing, has := service.entries[target]; has {
+ err = fmt.Errorf("duplicate SRV entries mapped to %s: (%d && %d)", target, port, existing)
+ return
+ }
+ service.entries[target] = port
+ }
+ return
+}
+
+func addDNSRecord(from string, to string, cfZoneID string) error {
+ cloudflareDNS := cloudflare.NewDNS(cfZoneID, cfEmail, cfAuthKey)
+
+ const priority = 1
+ const proxied = false
+
+ // If we need to register anything, first register a DNS entry
+ // to map our network DNS name to our public name (or IP) provided to nodecfg
+ // Network HostName = eg r1.testnet.algorand.network
+ isIP := net.ParseIP(to) != nil
+ var recordType string
+ if isIP {
+ recordType = "A"
+ } else {
+ recordType = "CNAME"
+ }
+ return cloudflareDNS.SetDNSRecord(context.Background(), recordType, from, to, cloudflare.AutomaticTTL, priority, proxied)
+}
+
+func addSRVRecord(srvNetwork string, target string, port uint16, serviceShortName string, cfZoneID string) error {
+ cloudflareDNS := cloudflare.NewDNS(cfZoneID, cfEmail, cfAuthKey)
+
+ const priority = 1
+ const weight = 1
+
+ return cloudflareDNS.SetSRVRecord(context.Background(), srvNetwork, target, cloudflare.AutomaticTTL, priority, uint(port), serviceShortName, "_tcp", weight)
+}