From c968b61b66eb6696bfda5e1038c902014d18d779 Mon Sep 17 00:00:00 2001 From: Felix Lange Date: Thu, 19 Sep 2019 14:50:07 +0200 Subject: [PATCH] cmd/devp2p: add frontend for p2p/dnsdisc This change adds the 'dns' subcommand family as a frontend for the p2p/dnsdisc library. The new commands can be used to update, sign and deploy DNS TXT records. --- cmd/devp2p/discv4cmd.go | 120 +++++++----- cmd/devp2p/dns_cloudflare.go | 163 ++++++++++++++++ cmd/devp2p/dnscmd.go | 352 +++++++++++++++++++++++++++++++++++ cmd/devp2p/main.go | 36 +++- cmd/devp2p/nodeset.go | 87 +++++++++ 5 files changed, 708 insertions(+), 50 deletions(-) create mode 100644 cmd/devp2p/dns_cloudflare.go create mode 100644 cmd/devp2p/dnscmd.go create mode 100644 cmd/devp2p/nodeset.go diff --git a/cmd/devp2p/discv4cmd.go b/cmd/devp2p/discv4cmd.go index 1e56687a6c..ab5b874029 100644 --- a/cmd/devp2p/discv4cmd.go +++ b/cmd/devp2p/discv4cmd.go @@ -19,10 +19,10 @@ package main import ( "fmt" "net" - "sort" "strings" "time" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/p2p/discover" "github.com/ethereum/go-ethereum/p2p/enode" @@ -38,23 +38,34 @@ var ( discv4PingCommand, discv4RequestRecordCommand, discv4ResolveCommand, + discv4ResolveJSONCommand, }, } discv4PingCommand = cli.Command{ - Name: "ping", - Usage: "Sends ping to a node", - Action: discv4Ping, + Name: "ping", + Usage: "Sends ping to a node", + Action: discv4Ping, + ArgsUsage: "", } discv4RequestRecordCommand = cli.Command{ - Name: "requestenr", - Usage: "Requests a node record using EIP-868 enrRequest", - Action: discv4RequestRecord, + Name: "requestenr", + Usage: "Requests a node record using EIP-868 enrRequest", + Action: discv4RequestRecord, + ArgsUsage: "", } discv4ResolveCommand = cli.Command{ - Name: "resolve", - Usage: "Finds a node in the DHT", - Action: discv4Resolve, - Flags: []cli.Flag{bootnodesFlag}, + Name: "resolve", + Usage: "Finds a node in the DHT", + Action: discv4Resolve, + ArgsUsage: "", + Flags: []cli.Flag{bootnodesFlag}, + } + discv4ResolveJSONCommand = cli.Command{ + Name: "resolve-json", + Usage: "Re-resolves nodes in a nodes.json file", + Action: discv4ResolveJSON, + Flags: []cli.Flag{bootnodesFlag}, + ArgsUsage: "", } ) @@ -64,10 +75,8 @@ var bootnodesFlag = cli.StringFlag{ } func discv4Ping(ctx *cli.Context) error { - n, disc, err := getNodeArgAndStartV4(ctx) - if err != nil { - return err - } + n := getNodeArg(ctx) + disc := startV4(ctx) defer disc.Close() start := time.Now() @@ -79,10 +88,8 @@ func discv4Ping(ctx *cli.Context) error { } func discv4RequestRecord(ctx *cli.Context) error { - n, disc, err := getNodeArgAndStartV4(ctx) - if err != nil { - return err - } + n := getNodeArg(ctx) + disc := startV4(ctx) defer disc.Close() respN, err := disc.RequestENR(n) @@ -94,33 +101,43 @@ func discv4RequestRecord(ctx *cli.Context) error { } func discv4Resolve(ctx *cli.Context) error { - n, disc, err := getNodeArgAndStartV4(ctx) - if err != nil { - return err - } + n := getNodeArg(ctx) + disc := startV4(ctx) defer disc.Close() fmt.Println(disc.Resolve(n).String()) return nil } -func getNodeArgAndStartV4(ctx *cli.Context) (*enode.Node, *discover.UDPv4, error) { - if ctx.NArg() != 1 { - return nil, nil, fmt.Errorf("missing node as command-line argument") +func discv4ResolveJSON(ctx *cli.Context) error { + if ctx.NArg() < 1 { + return fmt.Errorf("need nodes file as argument") } - n, err := parseNode(ctx.Args()[0]) - if err != nil { - return nil, nil, err + disc := startV4(ctx) + defer disc.Close() + file := ctx.Args().Get(0) + + // Load existing nodes in file. + var nodes []*enode.Node + if common.FileExist(file) { + nodes = loadNodesJSON(file).nodes() } - var bootnodes []*enode.Node - if commandHasFlag(ctx, bootnodesFlag) { - bootnodes, err = parseBootnodes(ctx) + // Add nodes from command line arguments. + for i := 1; i < ctx.NArg(); i++ { + n, err := parseNode(ctx.Args().Get(i)) if err != nil { - return nil, nil, err + exit(err) } + nodes = append(nodes, n) } - disc, err := startV4(bootnodes) - return n, disc, err + + result := make(nodeSet, len(nodes)) + for _, n := range nodes { + n = disc.Resolve(n) + result[n.ID()] = nodeJSON{Seq: n.Seq(), N: n} + } + writeNodesJSON(file, result) + return nil } func parseBootnodes(ctx *cli.Context) ([]*enode.Node, error) { @@ -139,28 +156,39 @@ func parseBootnodes(ctx *cli.Context) ([]*enode.Node, error) { return nodes, nil } -// commandHasFlag returns true if the current command supports the given flag. -func commandHasFlag(ctx *cli.Context, flag cli.Flag) bool { - flags := ctx.FlagNames() - sort.Strings(flags) - i := sort.SearchStrings(flags, flag.GetName()) - return i != len(flags) && flags[i] == flag.GetName() +// startV4 starts an ephemeral discovery V4 node. +func startV4(ctx *cli.Context) *discover.UDPv4 { + socket, ln, cfg, err := listen() + if err != nil { + exit(err) + } + if commandHasFlag(ctx, bootnodesFlag) { + bn, err := parseBootnodes(ctx) + if err != nil { + exit(err) + } + cfg.Bootnodes = bn + } + disc, err := discover.ListenV4(socket, ln, cfg) + if err != nil { + exit(err) + } + return disc } -// startV4 starts an ephemeral discovery V4 node. -func startV4(bootnodes []*enode.Node) (*discover.UDPv4, error) { +func listen() (*net.UDPConn, *enode.LocalNode, discover.Config, error) { var cfg discover.Config - cfg.Bootnodes = bootnodes cfg.PrivateKey, _ = crypto.GenerateKey() db, _ := enode.OpenDB("") ln := enode.NewLocalNode(db, cfg.PrivateKey) socket, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IP{0, 0, 0, 0}}) if err != nil { - return nil, err + db.Close() + return nil, nil, cfg, err } addr := socket.LocalAddr().(*net.UDPAddr) ln.SetFallbackIP(net.IP{127, 0, 0, 1}) ln.SetFallbackUDP(addr.Port) - return discover.ListenUDP(socket, ln, cfg) + return socket, ln, cfg, nil } diff --git a/cmd/devp2p/dns_cloudflare.go b/cmd/devp2p/dns_cloudflare.go new file mode 100644 index 0000000000..83279168cc --- /dev/null +++ b/cmd/devp2p/dns_cloudflare.go @@ -0,0 +1,163 @@ +// 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 ( + "fmt" + "strings" + + "github.com/cloudflare/cloudflare-go" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/p2p/dnsdisc" + "gopkg.in/urfave/cli.v1" +) + +var ( + cloudflareTokenFlag = cli.StringFlag{ + Name: "token", + Usage: "CloudFlare API token", + EnvVar: "CLOUDFLARE_API_TOKEN", + } + cloudflareZoneIDFlag = cli.StringFlag{ + Name: "zoneid", + Usage: "CloudFlare Zone ID (optional)", + } +) + +type cloudflareClient struct { + *cloudflare.API + zoneID string +} + +// newCloudflareClient sets up a CloudFlare API client from command line flags. +func newCloudflareClient(ctx *cli.Context) *cloudflareClient { + token := ctx.String(cloudflareTokenFlag.Name) + if token == "" { + exit(fmt.Errorf("need cloudflare API token to proceed")) + } + api, err := cloudflare.NewWithAPIToken(token) + if err != nil { + exit(fmt.Errorf("can't create Cloudflare client: %v", err)) + } + return &cloudflareClient{ + API: api, + zoneID: ctx.String(cloudflareZoneIDFlag.Name), + } +} + +// deploy uploads the given tree to CloudFlare DNS. +func (c *cloudflareClient) deploy(name string, t *dnsdisc.Tree) error { + if err := c.checkZone(name); err != nil { + return err + } + records := t.ToTXT(name) + return c.uploadRecords(name, records) +} + +// checkZone verifies permissions on the CloudFlare DNS Zone for name. +func (c *cloudflareClient) checkZone(name string) error { + if c.zoneID == "" { + log.Info(fmt.Sprintf("Finding CloudFlare zone ID for %s", name)) + id, err := c.ZoneIDByName(name) + if err != nil { + return err + } + c.zoneID = id + } + log.Info(fmt.Sprintf("Checking Permissions on zone %s", c.zoneID)) + zone, err := c.ZoneDetails(c.zoneID) + if err != nil { + return err + } + if !strings.HasSuffix(name, "."+zone.Name) { + return fmt.Errorf("CloudFlare zone name %q does not match name %q to be deployed", zone.Name, name) + } + needPerms := map[string]bool{"#zone:edit": false, "#zone:read": false} + for _, perm := range zone.Permissions { + if _, ok := needPerms[perm]; ok { + needPerms[perm] = true + } + } + for _, ok := range needPerms { + if !ok { + return fmt.Errorf("wrong permissions on zone %s: %v", c.zoneID, needPerms) + } + } + return nil +} + +// uploadRecords updates the TXT records at a particular subdomain. All non-root records +// will have a TTL of "infinity" and all existing records not in the new map will be +// nuked! +func (c *cloudflareClient) uploadRecords(name string, records map[string]string) 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 + + log.Info(fmt.Sprintf("Retrieving existing TXT records on %s", name)) + entries, err := c.DNSRecords(c.zoneID, cloudflare.DNSRecord{Type: "TXT"}) + if err != nil { + return err + } + existing := make(map[string]cloudflare.DNSRecord) + for _, entry := range entries { + if !strings.HasSuffix(entry.Name, name) { + continue + } + existing[strings.ToLower(entry.Name)] = entry + } + + // Iterate over the new records and inject anything missing. + for path, val := range records { + old, exists := existing[path] + if !exists { + // Entry is unknown, push a new one to Cloudflare. + log.Info(fmt.Sprintf("Creating %s = %q", path, val)) + ttl := 1 + if path != name { + ttl = 2147483647 // Max TTL permitted by Cloudflare + } + _, err = c.CreateDNSRecord(c.zoneID, cloudflare.DNSRecord{Type: "TXT", Name: path, Content: val, TTL: ttl}) + } else if old.Content != val { + // Entry already exists, only change its content. + log.Info(fmt.Sprintf("Updating %s from %q to %q", path, old.Content, val)) + old.Content = val + err = c.UpdateDNSRecord(c.zoneID, old.ID, old) + } else { + log.Info(fmt.Sprintf("Skipping %s = %q", path, val)) + } + if err != nil { + return fmt.Errorf("failed to publish %s: %v", path, err) + } + } + + // Iterate over the old records and delete anything stale. + for path, entry := range existing { + if _, ok := records[path]; ok { + continue + } + // Stale entry, nuke it. + log.Info(fmt.Sprintf("Deleting %s = %q", path, entry.Content)) + if err := c.DeleteDNSRecord(c.zoneID, entry.ID); err != nil { + return fmt.Errorf("failed to delete %s: %v", path, err) + } + } + return nil +} diff --git a/cmd/devp2p/dnscmd.go b/cmd/devp2p/dnscmd.go new file mode 100644 index 0000000000..6af3e938d3 --- /dev/null +++ b/cmd/devp2p/dnscmd.go @@ -0,0 +1,352 @@ +// Copyright 2018 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 ( + "crypto/ecdsa" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "time" + + "github.com/ethereum/go-ethereum/accounts/keystore" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/console" + "github.com/ethereum/go-ethereum/p2p/dnsdisc" + "github.com/ethereum/go-ethereum/p2p/enode" + cli "gopkg.in/urfave/cli.v1" +) + +var ( + dnsCommand = cli.Command{ + Name: "dns", + Usage: "DNS Discovery Commands", + Subcommands: []cli.Command{ + dnsSyncCommand, + dnsSignCommand, + dnsTXTCommand, + dnsCloudflareCommand, + }, + } + dnsSyncCommand = cli.Command{ + Name: "sync", + Usage: "Download a DNS discovery tree", + ArgsUsage: " [ ]", + Action: dnsSync, + Flags: []cli.Flag{dnsTimeoutFlag}, + } + dnsSignCommand = cli.Command{ + Name: "sign", + Usage: "Sign a DNS discovery tree", + ArgsUsage: " ", + Action: dnsSign, + Flags: []cli.Flag{dnsDomainFlag, dnsSeqFlag}, + } + dnsTXTCommand = cli.Command{ + Name: "to-txt", + Usage: "Create a DNS TXT records for a discovery tree", + ArgsUsage: " ", + Action: dnsToTXT, + } + dnsCloudflareCommand = cli.Command{ + Name: "to-cloudflare", + Usage: "Deploy DNS TXT records to cloudflare", + ArgsUsage: "", + Action: dnsToCloudflare, + Flags: []cli.Flag{cloudflareTokenFlag, cloudflareZoneIDFlag}, + } +) + +var ( + dnsTimeoutFlag = cli.DurationFlag{ + Name: "timeout", + Usage: "Timeout for DNS lookups", + } + dnsDomainFlag = cli.StringFlag{ + Name: "domain", + Usage: "Domain name of the tree", + } + dnsSeqFlag = cli.UintFlag{ + Name: "seq", + Usage: "New sequence number of the tree", + } +) + +// dnsSync performs dnsSyncCommand. +func dnsSync(ctx *cli.Context) error { + var ( + c = dnsClient(ctx) + url = ctx.Args().Get(0) + outdir = ctx.Args().Get(1) + ) + domain, _, err := dnsdisc.ParseURL(url) + if err != nil { + return err + } + if outdir == "" { + outdir = domain + } + + t, err := c.SyncTree(url) + if err != nil { + return err + } + def := treeToDefinition(url, t) + def.Meta.LastModified = time.Now() + writeTreeDefinition(outdir, def) + return nil +} + +func dnsSign(ctx *cli.Context) error { + if ctx.NArg() < 2 { + return fmt.Errorf("need tree definition directory and key file as arguments") + } + var ( + defdir = ctx.Args().Get(0) + keyfile = ctx.Args().Get(1) + def = loadTreeDefinition(defdir) + domain = directoryName(defdir) + ) + if def.Meta.URL != "" { + d, _, err := dnsdisc.ParseURL(def.Meta.URL) + if err != nil { + return fmt.Errorf("invalid 'url' field: %v", err) + } + domain = d + } + if ctx.IsSet(dnsDomainFlag.Name) { + domain = ctx.String(dnsDomainFlag.Name) + } + if ctx.IsSet(dnsSeqFlag.Name) { + def.Meta.Seq = uint(ctx.Uint(dnsSeqFlag.Name)) + } else { + def.Meta.Seq++ // Auto-bump sequence number if not supplied via flag. + } + t, err := dnsdisc.MakeTree(def.Meta.Seq, def.Nodes, def.Meta.Links) + if err != nil { + return err + } + + key := loadSigningKey(keyfile) + url, err := t.Sign(key, domain) + if err != nil { + return fmt.Errorf("can't sign: %v", err) + } + + def = treeToDefinition(url, t) + def.Meta.LastModified = time.Now() + writeTreeDefinition(defdir, def) + return nil +} + +func directoryName(dir string) string { + abs, err := filepath.Abs(dir) + if err != nil { + exit(err) + } + return filepath.Base(abs) +} + +// dnsToTXT peforms dnsTXTCommand. +func dnsToTXT(ctx *cli.Context) error { + if ctx.NArg() < 1 { + return fmt.Errorf("need tree definition directory as argument") + } + output := ctx.Args().Get(1) + if output == "" { + output = "-" // default to stdout + } + domain, t, err := loadTreeDefinitionForExport(ctx.Args().Get(0)) + if err != nil { + return err + } + writeTXTJSON(output, t.ToTXT(domain)) + return nil +} + +// dnsToCloudflare peforms dnsCloudflareCommand. +func dnsToCloudflare(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 := newCloudflareClient(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) + if err != nil { + exit(fmt.Errorf("failed to read the keyfile at '%s': %v", keyfile, err)) + } + password, _ := console.Stdin.PromptPassword("Please enter the password for '" + keyfile + "': ") + key, err := keystore.DecryptKey(keyjson, password) + if err != nil { + exit(fmt.Errorf("error decrypting key: %v", err)) + } + return key.PrivateKey +} + +// dnsClient configures the DNS discovery client from command line flags. +func dnsClient(ctx *cli.Context) *dnsdisc.Client { + var cfg dnsdisc.Config + if commandHasFlag(ctx, dnsTimeoutFlag) { + cfg.Timeout = ctx.Duration(dnsTimeoutFlag.Name) + } + c, _ := dnsdisc.NewClient(cfg) // cannot fail because no URLs given + return c +} + +// There are two file formats for DNS node trees on disk: +// +// The 'TXT' format is a single JSON file containing DNS TXT records +// as a JSON object where the keys are names and the values are objects +// containing the value of the record. +// +// The 'definition' format is a directory containing two files: +// +// enrtree-info.json -- contains sequence number & links to other trees +// nodes.json -- contains the nodes as a JSON array. +// +// This format exists because it's convenient to edit. nodes.json can be generated +// in multiple ways: it may be written by a DHT crawler or compiled by a human. + +type dnsDefinition struct { + Meta dnsMetaJSON + Nodes []*enode.Node +} + +type dnsMetaJSON struct { + URL string `json:"url,omitempty"` + Seq uint `json:"seq"` + Sig string `json:"signature"` + Links []string `json:"links,omitempty"` + LastModified time.Time `json:"lastModified"` +} + +func treeToDefinition(url string, t *dnsdisc.Tree) *dnsDefinition { + meta := dnsMetaJSON{ + URL: url, + Seq: t.Seq(), + Sig: t.Signature(), + Links: t.Links(), + } + return &dnsDefinition{Meta: meta, Nodes: t.Nodes()} +} + +// loadTreeDefinition loads a directory in 'definition' format. +func loadTreeDefinition(directory string) *dnsDefinition { + metaFile, nodesFile := treeDefinitionFiles(directory) + var def dnsDefinition + err := common.LoadJSON(metaFile, &def.Meta) + if err != nil && !os.IsNotExist(err) { + exit(err) + } + // Check link syntax. + for _, link := range def.Meta.Links { + if _, _, err := dnsdisc.ParseURL(link); err != nil { + exit(fmt.Errorf("invalid link %q: %v", link, err)) + } + } + // Check/convert nodes. + nodes := loadNodesJSON(nodesFile) + if err := nodes.verify(); err != nil { + exit(err) + } + def.Nodes = nodes.nodes() + return &def +} + +// loadTreeDefinitionForExport loads a DNS tree and ensures it is signed. +func loadTreeDefinitionForExport(dir string) (domain string, t *dnsdisc.Tree, err error) { + metaFile, _ := treeDefinitionFiles(dir) + def := loadTreeDefinition(dir) + if def.Meta.URL == "" { + return "", nil, fmt.Errorf("missing 'url' field in %v", metaFile) + } + domain, pubkey, err := dnsdisc.ParseURL(def.Meta.URL) + if err != nil { + return "", nil, fmt.Errorf("invalid 'url' field in %v: %v", metaFile, err) + } + if t, err = dnsdisc.MakeTree(def.Meta.Seq, def.Nodes, def.Meta.Links); err != nil { + return "", nil, err + } + if err := ensureValidTreeSignature(t, pubkey, def.Meta.Sig); err != nil { + return "", nil, err + } + return domain, t, nil +} + +// ensureValidTreeSignature checks that sig is valid for tree and assigns it as the +// tree's signature if valid. +func ensureValidTreeSignature(t *dnsdisc.Tree, pubkey *ecdsa.PublicKey, sig string) error { + if sig == "" { + return fmt.Errorf("missing signature, run 'devp2p dns sign' first") + } + if err := t.SetSignature(pubkey, sig); err != nil { + return fmt.Errorf("invalid signature on tree, run 'devp2p dns sign' to update it") + } + return nil +} + +// writeTreeDefinition writes a DNS node tree definition to the given directory. +func writeTreeDefinition(directory string, def *dnsDefinition) { + metaJSON, err := json.MarshalIndent(&def.Meta, "", jsonIndent) + if err != nil { + exit(err) + } + // Convert nodes. + nodes := make(nodeSet, len(def.Nodes)) + nodes.add(def.Nodes...) + // Write. + if err := os.Mkdir(directory, 0744); err != nil && !os.IsExist(err) { + exit(err) + } + metaFile, nodesFile := treeDefinitionFiles(directory) + writeNodesJSON(nodesFile, nodes) + if err := ioutil.WriteFile(metaFile, metaJSON, 0644); err != nil { + exit(err) + } +} + +func treeDefinitionFiles(directory string) (string, string) { + meta := filepath.Join(directory, "enrtree-info.json") + nodes := filepath.Join(directory, "nodes.json") + return meta, nodes +} + +// writeTXTJSON writes TXT records in JSON format. +func writeTXTJSON(file string, txt map[string]string) { + txtJSON, err := json.MarshalIndent(txt, "", jsonIndent) + if err != nil { + exit(err) + } + if file == "-" { + os.Stdout.Write(txtJSON) + fmt.Println() + return + } + if err := ioutil.WriteFile(file, txtJSON, 0644); err != nil { + exit(err) + } +} diff --git a/cmd/devp2p/main.go b/cmd/devp2p/main.go index 4532ab9683..c88fe6f612 100644 --- a/cmd/devp2p/main.go +++ b/cmd/devp2p/main.go @@ -20,8 +20,10 @@ import ( "fmt" "os" "path/filepath" + "sort" "github.com/ethereum/go-ethereum/internal/debug" + "github.com/ethereum/go-ethereum/p2p/enode" "github.com/ethereum/go-ethereum/params" "gopkg.in/urfave/cli.v1" ) @@ -57,12 +59,38 @@ func init() { app.Commands = []cli.Command{ enrdumpCommand, discv4Command, + dnsCommand, } } func main() { - if err := app.Run(os.Args); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } + exit(app.Run(os.Args)) +} + +// commandHasFlag returns true if the current command supports the given flag. +func commandHasFlag(ctx *cli.Context, flag cli.Flag) bool { + flags := ctx.FlagNames() + sort.Strings(flags) + i := sort.SearchStrings(flags, flag.GetName()) + return i != len(flags) && flags[i] == flag.GetName() +} + +// getNodeArg handles the common case of a single node descriptor argument. +func getNodeArg(ctx *cli.Context) *enode.Node { + if ctx.NArg() != 1 { + exit("missing node as command-line argument") + } + n, err := parseNode(ctx.Args()[0]) + if err != nil { + exit(err) + } + return n +} + +func exit(err interface{}) { + if err == nil { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } diff --git a/cmd/devp2p/nodeset.go b/cmd/devp2p/nodeset.go new file mode 100644 index 0000000000..a4a05016e9 --- /dev/null +++ b/cmd/devp2p/nodeset.go @@ -0,0 +1,87 @@ +// 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 ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "sort" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/p2p/enode" +) + +const jsonIndent = " " + +// nodeSet is the nodes.json file format. It holds a set of node records +// as a JSON object. +type nodeSet map[enode.ID]nodeJSON + +type nodeJSON struct { + Seq uint64 `json:"seq"` + N *enode.Node `json:"record"` +} + +func loadNodesJSON(file string) nodeSet { + var nodes nodeSet + if err := common.LoadJSON(file, &nodes); err != nil { + exit(err) + } + return nodes +} + +func writeNodesJSON(file string, nodes nodeSet) { + nodesJSON, err := json.MarshalIndent(nodes, "", jsonIndent) + if err != nil { + exit(err) + } + if err := ioutil.WriteFile(file, nodesJSON, 0644); err != nil { + exit(err) + } +} + +func (ns nodeSet) nodes() []*enode.Node { + result := make([]*enode.Node, 0, len(ns)) + for _, n := range ns { + result = append(result, n.N) + } + // Sort by ID. + sort.Slice(result, func(i, j int) bool { + return bytes.Compare(result[i].ID().Bytes(), result[j].ID().Bytes()) < 0 + }) + return result +} + +func (ns nodeSet) add(nodes ...*enode.Node) { + for _, n := range nodes { + ns[n.ID()] = nodeJSON{Seq: n.Seq(), N: n} + } +} + +func (ns nodeSet) verify() error { + for id, n := range ns { + if n.N.ID() != id { + return fmt.Errorf("invalid node %v: ID does not match ID %v in record", id, n.N.ID()) + } + if n.N.Seq() != n.Seq { + return fmt.Errorf("invalid node %v: 'seq' does not match seq %d from record", id, n.N.Seq()) + } + } + return nil +}