EnodeURLImpl.java
/*
* Copyright ConsenSys AG.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
package org.hyperledger.besu.ethereum.p2p.peers;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import org.hyperledger.besu.plugin.data.EnodeURL;
import org.hyperledger.besu.util.NetworkUtility;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.google.common.net.InetAddresses;
import com.google.common.primitives.Ints;
import org.apache.tuweni.bytes.Bytes;
public class EnodeURLImpl implements EnodeURL {
public static final int DEFAULT_LISTENING_PORT = 30303;
public static final int NODE_ID_SIZE = 64;
private static final Pattern DISCPORT_QUERY_STRING_REGEX =
Pattern.compile("^discport=([0-9]{1,5})$");
private static final Pattern NODE_ID_PATTERN = Pattern.compile("^[0-9a-fA-F]{128}$");
private final Bytes nodeId;
private InetAddress ip;
private final Optional<String> maybeHostname;
private final Optional<Integer> listeningPort;
private final Optional<Integer> discoveryPort;
private EnodeURLImpl(
final Bytes nodeId,
final InetAddress address,
final Optional<String> maybeHostname,
final Optional<Integer> listeningPort,
final Optional<Integer> discoveryPort) {
checkArgument(
nodeId.size() == NODE_ID_SIZE,
"Invalid node id of length " + nodeId.size() + ". Expected id of length: 64 bytes.");
listeningPort.ifPresent(port -> NetworkUtility.checkPort(port, "listening"));
discoveryPort.ifPresent(port -> NetworkUtility.checkPort(port, "discovery"));
this.nodeId = nodeId;
this.ip = address;
this.maybeHostname = maybeHostname;
this.listeningPort = listeningPort;
this.discoveryPort = discoveryPort;
}
public static Builder builder() {
return new Builder();
}
public static EnodeURL fromString(final String value) {
return fromString(value, EnodeDnsConfiguration.dnsDisabled());
}
public static EnodeURL fromString(
final String value, final EnodeDnsConfiguration enodeDnsConfiguration) {
try {
checkStringArgumentNotEmpty(value, "Invalid empty value.");
return fromURI(URI.create(value), enodeDnsConfiguration);
} catch (final IllegalArgumentException e) {
String message = "";
if (enodeDnsConfiguration.dnsEnabled() && !enodeDnsConfiguration.updateEnabled()) {
message =
String.format(
"Invalid IP address '%s' (or DNS query resolved an invalid IP). --Xdns-enabled is true but --Xdns-update-enabled flag is false.",
value);
} else {
message =
String.format(
"Invalid enode URL syntax '%s'. Enode URL should have the following format 'enode://<node_id>@<ip>:<listening_port>[?discport=<discovery_port>]'.",
value);
if (e.getMessage() != null) {
message += " " + e.getMessage();
}
}
throw new IllegalArgumentException(message, e);
}
}
public static EnodeURL fromURI(final URI uri) {
return fromURI(uri, EnodeDnsConfiguration.dnsDisabled());
}
public static EnodeURL fromURI(final URI uri, final EnodeDnsConfiguration enodeDnsConfiguration) {
checkArgument(uri != null, "URI cannot be null");
checkStringArgumentNotEmpty(uri.getScheme(), "Missing 'enode' scheme.");
checkStringArgumentNotEmpty(uri.getHost(), "Missing or invalid host or ip address.");
checkStringArgumentNotEmpty(uri.getUserInfo(), "Missing node ID.");
checkArgument(
uri.getScheme().equalsIgnoreCase("enode"), "Invalid URI scheme (must equal \"enode\").");
checkArgument(
NODE_ID_PATTERN.matcher(uri.getUserInfo()).matches(),
"Invalid node ID: node ID must have exactly 128 hexadecimal characters and should not include any '0x' hex prefix.");
final Bytes id = Bytes.fromHexString(uri.getUserInfo());
String host = uri.getHost();
int tcpPort = uri.getPort();
// Parse discport if it exists
Optional<Integer> discoveryPort = Optional.empty();
String query = uri.getQuery();
if (query != null) {
final Matcher discPortMatcher = DISCPORT_QUERY_STRING_REGEX.matcher(query);
if (discPortMatcher.matches()) {
discoveryPort = Optional.ofNullable(Ints.tryParse(discPortMatcher.group(1)));
}
checkArgument(discoveryPort.isPresent(), "Invalid discovery port: '" + query + "'.");
} else {
discoveryPort = Optional.of(tcpPort);
}
return builder()
.ipAddress(host, enodeDnsConfiguration)
.nodeId(id)
.listeningPort(tcpPort)
.discoveryPort(discoveryPort)
.build();
}
private static void checkStringArgumentNotEmpty(final String argument, final String message) {
checkArgument(argument != null && !argument.trim().isEmpty(), message);
}
public static boolean sameListeningEndpoint(final EnodeURL enodeA, final EnodeURL enodeB) {
if (enodeA == null || enodeB == null) {
return false;
}
return Objects.equals(enodeA.getNodeId(), enodeB.getNodeId())
&& Objects.equals(enodeA.getIp(), enodeB.getIp())
&& Objects.equals(enodeA.getListeningPort(), enodeB.getListeningPort());
}
public static Bytes parseNodeId(final String nodeId) {
int expectedSize = EnodeURLImpl.NODE_ID_SIZE * 2;
if (nodeId.toLowerCase(Locale.ROOT).startsWith("0x")) {
expectedSize += 2;
}
checkArgument(
nodeId.length() == expectedSize,
"Expected " + EnodeURLImpl.NODE_ID_SIZE + " bytes in " + nodeId);
return Bytes.fromHexString(nodeId, NODE_ID_SIZE);
}
@Override
public URI toURI() {
final String uri =
String.format(
"enode://%s@%s:%d",
nodeId.toUnprefixedHexString(),
maybeHostname.orElse(InetAddresses.toUriString(getIp())),
getListeningPortOrZero());
final OptionalInt discPort = getDiscPortQueryParam();
if (discPort.isPresent()) {
return URI.create(uri + String.format("?discport=%d", discPort.getAsInt()));
} else {
return URI.create(uri);
}
}
@Override
public URI toURIWithoutDiscoveryPort() {
final String uri =
String.format(
"enode://%s@%s:%d",
nodeId.toUnprefixedHexString(),
maybeHostname.orElse(InetAddresses.toUriString(getIp())),
getListeningPortOrZero());
return URI.create(uri);
}
/**
* Returns the discovery port only if it differs from the listening port
*
* @return The port, as an optional.
*/
private OptionalInt getDiscPortQueryParam() {
final int listeningPort = getListeningPortOrZero();
final int discoveryPort = getDiscoveryPortOrZero();
if (listeningPort == discoveryPort) {
return OptionalInt.empty();
}
return OptionalInt.of(discoveryPort);
}
public static URI asURI(final String url) {
return asURI(url, EnodeDnsConfiguration.dnsDisabled());
}
public static URI asURI(final String url, final EnodeDnsConfiguration enodeDnsConfiguration) {
return fromString(url, enodeDnsConfiguration).toURI();
}
@Override
public Bytes getNodeId() {
return nodeId;
}
@Override
public String getIpAsString() {
return getIp().getHostAddress();
}
/**
* Get IP of the EnodeURL
*
* <p>If "dns" and "dns-update" are enabled -> DNS lookup every time to have the IP up to date
* and not to rely on an invalid cache
*
* <p>If the "dns" is enabled but "dns-update" is disabled -> IP is retrieved only one time and
* the hostname is no longer stored (maybeHostname is empty).
*
* @return ip
*/
@Override
public InetAddress getIp() {
this.ip =
maybeHostname
.map(
hostname -> {
try {
return InetAddress.getByName(hostname);
} catch (final UnknownHostException e) {
return ip;
}
})
.orElse(ip);
return ip;
}
@Override
public boolean isListening() {
return listeningPort.isPresent();
}
@Override
public boolean isRunningDiscovery() {
return discoveryPort.isPresent();
}
@Override
public Optional<Integer> getListeningPort() {
return listeningPort;
}
@Override
public int getListeningPortOrZero() {
return listeningPort.orElse(0);
}
@Override
public Optional<Integer> getDiscoveryPort() {
return discoveryPort;
}
@Override
public int getDiscoveryPortOrZero() {
return discoveryPort.orElse(0);
}
@Override
public String getHost() {
final URI uriWithoutDiscoveryPort = toURIWithoutDiscoveryPort();
String host = uriWithoutDiscoveryPort.getHost();
if (host == null) {
host = "";
final String uriString = uriWithoutDiscoveryPort.toString();
int indexOfAt = uriString.indexOf("@");
if (indexOfAt > -1) {
int lastIndexOfColon = uriString.lastIndexOf(":");
if (lastIndexOfColon > indexOfAt) {
host = uriString.substring(indexOfAt + 1, lastIndexOfColon);
}
}
}
return host;
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final EnodeURL enodeURL = (EnodeURL) o;
return Objects.equals(getNodeId(), enodeURL.getNodeId())
&& Objects.equals(getIp(), enodeURL.getIp())
&& Objects.equals(getListeningPort(), enodeURL.getListeningPort())
&& Objects.equals(getDiscoveryPort(), enodeURL.getDiscoveryPort());
}
@Override
public int hashCode() {
return Objects.hash(getNodeId(), getIp(), getListeningPort(), getDiscoveryPort());
}
@Override
public String toString() {
return this.toURI().toString();
}
public static class Builder {
private Bytes nodeId;
private Optional<Integer> listeningPort;
private Optional<Integer> discoveryPort;
private Optional<String> maybeHostname = Optional.empty();
private InetAddress ip;
private Builder() {}
public EnodeURL build() {
validate();
return new EnodeURLImpl(nodeId, ip, maybeHostname, listeningPort, discoveryPort);
}
private void validate() {
checkState(nodeId != null, "Node id must be configured.");
checkState(listeningPort != null, "Listening port must be configured.");
checkState(discoveryPort != null, "Discovery port must be configured.");
checkState(ip != null, "Ip address must be configured.");
}
public Builder configureFromEnode(final EnodeURL enode) {
return this.nodeId(enode.getNodeId())
.listeningPort(enode.getListeningPort())
.discoveryPort(enode.getDiscoveryPort())
.ipAddress(enode.getIp());
}
public Builder nodeId(final Bytes nodeId) {
this.nodeId = nodeId;
return this;
}
public Builder nodeId(final byte[] nodeId) {
this.nodeId = Bytes.wrap(nodeId);
return this;
}
public Builder nodeId(final String nodeId) {
this.nodeId = Bytes.fromHexString(nodeId);
return this;
}
public Builder ipAddress(final InetAddress ip) {
this.ip = ip;
return this;
}
public Builder ipAddress(final String ip) {
return ipAddress(ip, EnodeDnsConfiguration.dnsDisabled());
}
public Builder ipAddress(final String ip, final EnodeDnsConfiguration enodeDnsConfiguration) {
if (enodeDnsConfiguration.dnsEnabled()) {
try {
this.ip = InetAddress.getByName(ip);
if (enodeDnsConfiguration.updateEnabled()) {
if (this.ip.isLoopbackAddress()) {
this.ip = InetAddress.getLocalHost();
}
this.maybeHostname = Optional.of(this.ip.getHostName());
}
} catch (final UnknownHostException e) {
if (!enodeDnsConfiguration.updateEnabled()) {
throw new IllegalArgumentException("Invalid ip address or hostname.");
} else {
this.ip = InetAddresses.forString("127.0.0.1");
}
}
} else if (InetAddresses.isUriInetAddress(ip)) {
this.ip = InetAddresses.forUriString(ip);
} else if (InetAddresses.isInetAddress(ip)) {
this.ip = InetAddresses.forString(ip);
} else {
throw new IllegalArgumentException("Invalid ip address.");
}
return this;
}
public Builder discoveryAndListeningPorts(final int listeningAndDiscoveryPorts) {
listeningPort(listeningAndDiscoveryPorts);
discoveryPort(listeningAndDiscoveryPorts);
return this;
}
public Builder disableListening() {
this.listeningPort = Optional.empty();
return this;
}
public Builder disableDiscovery() {
this.discoveryPort = Optional.empty();
return this;
}
public Builder useDefaultPorts() {
discoveryAndListeningPorts(EnodeURLImpl.DEFAULT_LISTENING_PORT);
return this;
}
/**
* An optional listening port value. If the value is empty of equal to 0, the listening port
* will be empty - indicating the corresponding node is not listening.
*
* @param maybeListeningPort If non-empty represents the port to listen on, if empty means the
* node is not listening
* @return The modified builder
*/
public Builder listeningPort(final Optional<Integer> maybeListeningPort) {
this.listeningPort = maybeListeningPort.filter(port -> port != 0);
return this;
}
/**
* An listening port value. A value of 0 means the node is not listening.
*
* @param listeningPort If non-zero, represents the port on which to listen for connections. A
* value of 0 means the node is not listening for connections.
* @return The modified builder
*/
public Builder listeningPort(final int listeningPort) {
return listeningPort(Optional.of(listeningPort));
}
/**
* The port on which to listen for discovery messages. A value that is empty or equal to 0,
* indicates that the node is not listening for discovery messages.
*
* @param maybeDiscoveryPort If non-empty and non-zero, represents the port on which to listen
* for discovery messages. Otherwise, indicates that the node is not running discovery.
* @return The modified builder
*/
public Builder discoveryPort(final Optional<Integer> maybeDiscoveryPort) {
this.discoveryPort = maybeDiscoveryPort.filter(port -> port != 0);
return this;
}
/**
* The port on which to listen for discovery messages. A value that is equal to 0, indicates
* that the node is not listening for discovery messages.
*
* @param discoveryPort If non-zero, represents the port on which to listen for discovery
* messages. Otherwise, indicates that the node is not running discovery.
* @return The modified builder
*/
public Builder discoveryPort(final int discoveryPort) {
return discoveryPort(Optional.of(discoveryPort));
}
}
}