Skip to content

Commit

Permalink
p2p: add tests and fix bugs for NodeAddress and NodeID (#6021)
Browse files Browse the repository at this point in the history
This renames `PeerAddress` to `NodeAddress`, moves it and `NodeID` into a separate file `address.go`, adds tests for them, and fixes a bunch of bugs and inconsistencies.
  • Loading branch information
erikgrinaker authored Feb 1, 2021
1 parent 1f39f80 commit fc71882
Show file tree
Hide file tree
Showing 11 changed files with 661 additions and 258 deletions.
227 changes: 227 additions & 0 deletions p2p/address.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package p2p

import (
"context"
"encoding/hex"
"errors"
"fmt"
"net"
"net/url"
"regexp"
"strconv"
"strings"

"github.com/tendermint/tendermint/crypto"
)

const (
// NodeIDByteLength is the length of a crypto.Address. Currently only 20.
// FIXME: support other length addresses?
NodeIDByteLength = crypto.AddressSize
)

var (
// reNodeID is a regexp for valid node IDs.
reNodeID = regexp.MustCompile(`^[0-9a-f]{40}$`)

// reHasScheme tries to detect URLs with schemes. It looks for a : before a / (if any).
reHasScheme = regexp.MustCompile(`^[^/]+:`)

// reSchemeIsHost tries to detect URLs where the scheme part is instead a
// hostname, i.e. of the form "host:80/path" where host: is a hostname.
reSchemeIsHost = regexp.MustCompile(`^[^/:]+:\d+(/|$)`)
)

// NodeID is a hex-encoded crypto.Address. It must be lowercased
// (for uniqueness) and of length 2*NodeIDByteLength.
type NodeID string

// NewNodeID returns a lowercased (normalized) NodeID, or errors if the
// node ID is invalid.
func NewNodeID(nodeID string) (NodeID, error) {
n := NodeID(strings.ToLower(nodeID))
return n, n.Validate()
}

// NodeIDFromPubKey creates a node ID from a given PubKey address.
func NodeIDFromPubKey(pubKey crypto.PubKey) NodeID {
return NodeID(hex.EncodeToString(pubKey.Address()))
}

// Bytes converts the node ID to its binary byte representation.
func (id NodeID) Bytes() ([]byte, error) {
bz, err := hex.DecodeString(string(id))
if err != nil {
return nil, fmt.Errorf("invalid node ID encoding: %w", err)
}
return bz, nil
}

// Validate validates the NodeID.
func (id NodeID) Validate() error {
switch {
case len(id) == 0:
return errors.New("empty node ID")

case len(id) != 2*NodeIDByteLength:
return fmt.Errorf("invalid node ID length %d, expected %d", len(id), 2*NodeIDByteLength)

case !reNodeID.MatchString(string(id)):
return fmt.Errorf("node ID can only contain lowercased hex digits")

default:
return nil
}
}

// NodeAddress is a node address URL. It differs from a transport Endpoint in
// that it contains the node's ID, and that the address hostname may be resolved
// into multiple IP addresses (and thus multiple endpoints).
//
// If the URL is opaque, i.e. of the form "scheme:opaque", then the opaque part
// is expected to contain a node ID.
type NodeAddress struct {
NodeID NodeID
Protocol Protocol
Hostname string
Port uint16
Path string
}

// ParseNodeAddress parses a node address URL into a NodeAddress, normalizing
// and validating it.
func ParseNodeAddress(urlString string) (NodeAddress, error) {
// url.Parse requires a scheme, so if it fails to parse a scheme-less URL
// we try to apply a default scheme.
url, err := url.Parse(urlString)
if (err != nil || url.Scheme == "") &&
(!reHasScheme.MatchString(urlString) || reSchemeIsHost.MatchString(urlString)) {
url, err = url.Parse(string(defaultProtocol) + "://" + urlString)
}
if err != nil {
return NodeAddress{}, fmt.Errorf("invalid node address %q: %w", urlString, err)
}

address := NodeAddress{
Protocol: Protocol(strings.ToLower(url.Scheme)),
}

// Opaque URLs are expected to contain only a node ID, also used as path.
if url.Opaque != "" {
address.NodeID = NodeID(url.Opaque)
address.Path = url.Opaque
return address, address.Validate()
}

// Otherwise, just parse a normal networked URL.
if url.User != nil {
address.NodeID = NodeID(strings.ToLower(url.User.Username()))
}

address.Hostname = strings.ToLower(url.Hostname())

if portString := url.Port(); portString != "" {
port64, err := strconv.ParseUint(portString, 10, 16)
if err != nil {
return NodeAddress{}, fmt.Errorf("invalid port %q: %w", portString, err)
}
address.Port = uint16(port64)
}

address.Path = url.Path
if url.RawQuery != "" {
address.Path += "?" + url.RawQuery
}
if url.Fragment != "" {
address.Path += "#" + url.Fragment
}
if address.Path != "" {
switch address.Path[0] {
case '/', '#', '?':
default:
address.Path = "/" + address.Path
}
}

return address, address.Validate()
}

// Resolve resolves a NodeAddress into a set of Endpoints, by expanding
// out a DNS hostname to IP addresses.
func (a NodeAddress) Resolve(ctx context.Context) ([]Endpoint, error) {
if a.Protocol == "" {
return nil, errors.New("address has no protocol")
}

// If there is no hostname, this is an opaque URL in the form
// "scheme:opaque", and the opaque part is assumed to be node ID used as
// Path.
if a.Hostname == "" {
if a.NodeID == "" {
return nil, errors.New("local address has no node ID")
}
return []Endpoint{{
Protocol: a.Protocol,
Path: string(a.NodeID),
}}, nil
}

ips, err := net.DefaultResolver.LookupIP(ctx, "ip", a.Hostname)
if err != nil {
return nil, err
}
endpoints := make([]Endpoint, len(ips))
for i, ip := range ips {
endpoints[i] = Endpoint{
Protocol: a.Protocol,
IP: ip,
Port: a.Port,
Path: a.Path,
}
}
return endpoints, nil
}

// String formats the address as a URL string.
func (a NodeAddress) String() string {
u := url.URL{Scheme: string(a.Protocol)}
if a.NodeID != "" {
u.User = url.User(string(a.NodeID))
}
switch {
case a.Hostname != "":
if a.Port > 0 {
u.Host = net.JoinHostPort(a.Hostname, strconv.Itoa(int(a.Port)))
} else {
u.Host = a.Hostname
}
u.Path = a.Path

case a.Protocol != "" && (a.Path == "" || a.Path == string(a.NodeID)):
u.User = nil
u.Opaque = string(a.NodeID) // e.g. memory:id

case a.Path != "" && a.Path[0] != '/':
u.Path = "/" + a.Path // e.g. some/path

default:
u.Path = a.Path // e.g. /some/path
}
return strings.TrimPrefix(u.String(), "//")
}

// Validate validates a NodeAddress.
func (a NodeAddress) Validate() error {
if a.Protocol == "" {
return errors.New("no protocol")
}
if a.NodeID == "" {
return errors.New("no peer ID")
} else if err := a.NodeID.Validate(); err != nil {
return fmt.Errorf("invalid peer ID: %w", err)
}
if a.Port > 0 && a.Hostname == "" {
return errors.New("cannot specify port without hostname")
}
return nil
}
Loading

0 comments on commit fc71882

Please sign in to comment.