Skip to content

Commit

Permalink
feat(p2p): support IPv6 (#1144)
Browse files Browse the repository at this point in the history
  • Loading branch information
luislhl authored Nov 21, 2024
1 parent d91513e commit 4da0fb9
Show file tree
Hide file tree
Showing 20 changed files with 451 additions and 25 deletions.
15 changes: 15 additions & 0 deletions hathor/builder/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ def __init__(self) -> None:
self._poa_signer: PoaSigner | None = None
self._poa_block_producer: PoaBlockProducer | None = None

self._enable_ipv6: bool = False
self._disable_ipv4: bool = False

def build(self) -> BuildArtifacts:
if self.artifacts is not None:
raise ValueError('cannot call build twice')
Expand Down Expand Up @@ -426,6 +429,8 @@ def _get_or_create_p2p_manager(self) -> ConnectionsManager:
ssl=enable_ssl,
whitelist_only=False,
rng=self._rng,
enable_ipv6=self._enable_ipv6,
disable_ipv4=self._disable_ipv4,
)
SyncSupportLevel.add_factories(
self._get_or_create_settings(),
Expand Down Expand Up @@ -812,6 +817,16 @@ def disable_full_verification(self) -> 'Builder':
self._full_verification = False
return self

def enable_ipv6(self) -> 'Builder':
self.check_if_can_modify()
self._enable_ipv6 = True
return self

def disable_ipv4(self) -> 'Builder':
self.check_if_can_modify()
self._disable_ipv4 = True
return self

def set_soft_voided_tx_ids(self, soft_voided_tx_ids: set[bytes]) -> 'Builder':
self.check_if_can_modify()
self._soft_voided_tx_ids = soft_voided_tx_ids
Expand Down
2 changes: 2 additions & 0 deletions hathor/builder/cli_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,8 @@ def create_manager(self, reactor: Reactor) -> HathorManager:
ssl=True,
whitelist_only=False,
rng=Random(),
enable_ipv6=self._args.x_enable_ipv6,
disable_ipv4=self._args.x_disable_ipv4,
)

vertex_handler = VertexHandler(
Expand Down
3 changes: 2 additions & 1 deletion hathor/cli/nginx_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,12 @@ def generate_nginx_config(openapi: dict[str, Any], *, out_file: TextIO, rate_k:

server_open = f'''
upstream backend {{
server fullnode:8080;
server 127.0.0.1:8080;
}}
server {{
listen 80;
listen [::]:80;
server_name localhost;
# Look for client IP in the X-Forwarded-For header
Expand Down
29 changes: 27 additions & 2 deletions hathor/cli/run_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ def create_parser(cls) -> ArgumentParser:
help='Address to listen for new connections (eg: tcp:8000)')
parser.add_argument('--bootstrap', action='append', help='Address to connect to (eg: tcp:127.0.0.1:8000')
parser.add_argument('--status', type=int, help='Port to run status server')
parser.add_argument('--x-status-ipv6-interface', help='IPv6 interface to bind the status server')
parser.add_argument('--stratum', type=int, help='Port to run stratum server')
parser.add_argument('--x-stratum-ipv6-interface', help='IPv6 interface to bind the stratum server')
parser.add_argument('--data', help='Data directory')
storage = parser.add_mutually_exclusive_group()
storage.add_argument('--rocksdb-storage', action='store_true', help='Use RocksDB storage backend (default)')
Expand Down Expand Up @@ -162,6 +164,10 @@ def create_parser(cls) -> ArgumentParser:
help='Log tx bytes for debugging')
parser.add_argument('--disable-ws-history-streaming', action='store_true',
help='Disable websocket history streaming API')
parser.add_argument('--x-enable-ipv6', action='store_true',
help='Enables listening on IPv6 interface and connecting to IPv6 peers')
parser.add_argument('--x-disable-ipv4', action='store_true',
help='Disables connecting to IPv4 peers')
return parser

def prepare(self, *, register_resources: bool = True) -> None:
Expand All @@ -181,6 +187,7 @@ def prepare(self, *, register_resources: bool = True) -> None:
print('Maximum number of open file descriptors is too low. Minimum required is 256.')
sys.exit(-2)

self.validate_args()
self.check_unsafe_arguments()
self.check_python_version()

Expand All @@ -202,7 +209,15 @@ def prepare(self, *, register_resources: bool = True) -> None:

if self._args.stratum:
assert self.manager.stratum_factory is not None
self.reactor.listenTCP(self._args.stratum, self.manager.stratum_factory)

if self._args.x_enable_ipv6:
interface = self._args.x_stratum_ipv6_interface or '::0'
# Linux by default will map IPv4 to IPv6, so listening only in the IPv6 interface will be
# enough to handle IPv4 connections. There is a kernel parameter that controls this behavior:
# https://sysctl-explorer.net/net/ipv6/bindv6only/
self.reactor.listenTCP(self._args.stratum, self.manager.stratum_factory, interface=interface)
else:
self.reactor.listenTCP(self._args.stratum, self.manager.stratum_factory)

from hathor.conf.get_settings import get_global_settings
settings = get_global_settings()
Expand All @@ -217,7 +232,12 @@ def prepare(self, *, register_resources: bool = True) -> None:
status_server = resources_builder.build()
if self._args.status:
assert status_server is not None
self.reactor.listenTCP(self._args.status, status_server)

if self._args.x_enable_ipv6:
interface = self._args.x_status_ipv6_interface or '::0'
self.reactor.listenTCP(self._args.status, status_server, interface=interface)
else:
self.reactor.listenTCP(self._args.status, status_server)

self.start_manager()

Expand Down Expand Up @@ -351,6 +371,11 @@ def run_sysctl_from_signal(self) -> None:
except SysctlRunnerException as e:
self.log.warn('[USR2] Error', errmsg=str(e))

def validate_args(self) -> None:
if self._args.x_disable_ipv4 and not self._args.x_enable_ipv6:
self.log.critical('You must enable IPv6 if you disable IPv4.')
sys.exit(-1)

def check_unsafe_arguments(self) -> None:
unsafe_args_found = []
for arg_cmdline, arg_test_fn in self.UNSAFE_ARGUMENTS:
Expand Down
4 changes: 4 additions & 0 deletions hathor/cli/run_node_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ class RunNodeArgs(BaseModel, extra=Extra.allow):
listen: list[str]
bootstrap: Optional[list[str]]
status: Optional[int]
x_status_ipv6_interface: Optional[str]
stratum: Optional[int]
x_stratum_ipv6_interface: Optional[str]
data: Optional[str]
rocksdb_storage: bool
memory_storage: bool
Expand Down Expand Up @@ -83,3 +85,5 @@ class RunNodeArgs(BaseModel, extra=Extra.allow):
nano_testnet: bool
log_vertex_bytes: bool
disable_ws_history_streaming: bool
x_enable_ipv6: bool
x_disable_ipv4: bool
1 change: 1 addition & 0 deletions hathor/conf/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ def GENESIS_TX2_TIMESTAMP(self) -> int:
CAPABILITY_WHITELIST: str = 'whitelist'
CAPABILITY_SYNC_VERSION: str = 'sync-version'
CAPABILITY_GET_BEST_BLOCKCHAIN: str = 'get-best-blockchain'
CAPABILITY_IPV6: str = 'ipv6' # peers announcing this capability will be relayed ipv6 entrypoints from other peers

# Where to download whitelist from
WHITELIST_URL: Optional[str] = None
Expand Down
3 changes: 2 additions & 1 deletion hathor/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,8 @@ def get_default_capabilities(self) -> list[str]:
return [
self._settings.CAPABILITY_WHITELIST,
self._settings.CAPABILITY_SYNC_VERSION,
self._settings.CAPABILITY_GET_BEST_BLOCKCHAIN
self._settings.CAPABILITY_GET_BEST_BLOCKCHAIN,
self._settings.CAPABILITY_IPV6,
]

def start(self) -> None:
Expand Down
32 changes: 30 additions & 2 deletions hathor/p2p/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ def __init__(
ssl: bool,
rng: Random,
whitelist_only: bool,
enable_ipv6: bool,
disable_ipv4: bool,
) -> None:
self.log = logger.new()
self._settings = settings
Expand Down Expand Up @@ -190,6 +192,12 @@ def __init__(
# Parameter to explicitly enable whitelist-only mode, when False it will still check the whitelist for sync-v1
self.whitelist_only = whitelist_only

# Parameter to enable IPv6 connections
self.enable_ipv6 = enable_ipv6

# Parameter to disable IPv4 connections
self.disable_ipv4 = disable_ipv4

# Timestamp when the last discovery ran
self._last_discovery: float = 0.

Expand Down Expand Up @@ -577,7 +585,11 @@ def _update_whitelist_cb(self, body: bytes) -> None:
def connect_to_if_not_connected(self, peer: UnverifiedPeer | PublicPeer, now: int) -> None:
""" Attempts to connect if it is not connected to the peer.
"""
if not peer.info.entrypoints:
if not peer.info.entrypoints or (
not self.enable_ipv6 and not peer.info.get_ipv4_only_entrypoints()
) or (
self.disable_ipv4 and not peer.info.get_ipv6_only_entrypoints()
):
# It makes no sense to keep storing peers that have disconnected and have no entrypoints
# We will never be able to connect to them anymore and they will only keep spending memory
# and other resources when used in APIs, so we are removing them here
Expand All @@ -589,7 +601,15 @@ def connect_to_if_not_connected(self, peer: UnverifiedPeer | PublicPeer, now: in

assert peer.id is not None
if peer.info.can_retry(now):
addr = self.rng.choice(peer.info.entrypoints)
if self.enable_ipv6 and not self.disable_ipv4:
addr = self.rng.choice(peer.info.entrypoints)
elif self.enable_ipv6 and self.disable_ipv4:
addr = self.rng.choice(peer.info.get_ipv6_only_entrypoints())
elif not self.enable_ipv6 and not self.disable_ipv4:
addr = self.rng.choice(peer.info.get_ipv4_only_entrypoints())
else:
raise ValueError('IPv4 is disabled and IPv6 is not enabled')

self.connect_to(addr.with_id(peer.id), peer)

def _connect_to_callback(
Expand Down Expand Up @@ -636,6 +656,14 @@ def connect_to(
self.log.debug('skip because of simple localhost check', entrypoint=str(entrypoint))
return

if not self.enable_ipv6 and entrypoint.addr.is_ipv6():
self.log.info('skip because IPv6 is disabled', entrypoint=entrypoint)
return

if self.disable_ipv4 and entrypoint.addr.is_ipv4():
self.log.info('skip because IPv4 is disabled', entrypoint=entrypoint)
return

if use_ssl is None:
use_ssl = self.use_ssl

Expand Down
21 changes: 19 additions & 2 deletions hathor/p2p/peer.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ class PeerInfo:
flags: set[str] = field(default_factory=set)
_settings: HathorSettings = field(default_factory=get_global_settings, repr=False)

def get_ipv4_only_entrypoints(self) -> list[PeerAddress]:
return list(filter(lambda e: not e.is_ipv6(), self.entrypoints))

def get_ipv6_only_entrypoints(self) -> list[PeerAddress]:
return list(filter(lambda e: e.is_ipv6(), self.entrypoints))

def ipv4_entrypoints_as_str(self) -> list[str]:
return list(map(str, self.get_ipv4_only_entrypoints()))

def ipv6_entrypoints_as_str(self) -> list[str]:
return list(map(str, self.get_ipv6_only_entrypoints()))

def entrypoints_as_str(self) -> list[str]:
"""Return a list of entrypoints serialized as str"""
return list(map(str, self.entrypoints))
Expand Down Expand Up @@ -203,14 +215,19 @@ class UnverifiedPeer:
id: PeerId
info: PeerInfo = field(default_factory=PeerInfo)

def to_json(self) -> dict[str, Any]:
def to_json(self, only_ipv4_entrypoints: bool = True) -> dict[str, Any]:
""" Return a JSON serialization of the object.
This format is compatible with libp2p.
"""
if only_ipv4_entrypoints:
entrypoints_as_str = self.info.ipv4_entrypoints_as_str()
else:
entrypoints_as_str = self.info.entrypoints_as_str()

return {
'id': str(self.id),
'entrypoints': self.info.entrypoints_as_str(),
'entrypoints': entrypoints_as_str,
}

@classmethod
Expand Down
61 changes: 55 additions & 6 deletions hathor/p2p/peer_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@

from __future__ import annotations

import re
from dataclasses import dataclass
from enum import Enum
from typing import Any
from urllib.parse import parse_qs, urlparse

from twisted.internet.address import IPv4Address, IPv6Address
from twisted.internet.endpoints import TCP4ClientEndpoint
from twisted.internet.endpoints import TCP4ClientEndpoint, TCP6ClientEndpoint
from twisted.internet.interfaces import IAddress, IStreamClientEndpoint
from typing_extensions import Self

Expand All @@ -32,6 +33,37 @@
'instead, compare the addr attribute explicitly, and if relevant, the peer_id too.'
)

"""
This Regex will match any valid IPv6 address.
Some examples that will match:
'::'
'::1'
'2001:0db8:85a3:0000:0000:8a2e:0370:7334'
'2001:db8:85a3:0:0:8a2e:370:7334'
'2001:db8::8a2e:370:7334'
'2001:db8:0:0:0:0:2:1'
'1234::5678'
'fe80::'
'::abcd:abcd:abcd:abcd:abcd:abcd'
'0:0:0:0:0:0:0:1'
'0:0:0:0:0:0:0:0'
Some examples that won't match:
'127.0.0.1' --> # IPv4
'1200::AB00:1234::2552:7777:1313' --> # double '::'
'2001:db8::g123' --> # invalid character
'2001:db8::85a3::7334' --> # double '::'
'2001:db8:85a3:0000:0000:8a2e:0370:7334:1234' --> # too many groups
'12345::abcd' --> # too many characters in a group
'2001:db8:85a3:8a2e:0370' --> # too few groups
'2001:db8:85a3::8a2e:3707334' --> # too many characters in a group
'1234:56789::abcd' --> # too many characters in a group
':2001:db8::1' --> # invalid start
'2001:db8::1:' --> # invalid end
"""
IPV6_REGEX = re.compile(r'''^(([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:)|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$''') # noqa: E501


class Protocol(Enum):
TCP = 'tcp'
Expand All @@ -46,7 +78,11 @@ class PeerAddress:
port: int

def __str__(self) -> str:
return f'{self.protocol.value}://{self.host}:{self.port}'
host = self.host
if self.is_ipv6():
host = f'[{self.host}]'

return f'{self.protocol.value}://{host}:{self.port}'

def __eq__(self, other: Any) -> bool:
"""
Expand Down Expand Up @@ -138,9 +174,11 @@ def from_address(cls, address: IAddress) -> Self:

def to_client_endpoint(self, reactor: Reactor) -> IStreamClientEndpoint:
"""This method generates a twisted client endpoint that has a .connect() method."""
# XXX: currently we don't support IPv6, but when we do we have to decide between TCP4ClientEndpoint and
# TCP6ClientEndpoint, when the host is an IP address that is easy, but when it is a DNS hostname, we will not
# know which to use until we know which resource records it holds (A or AAAA)
# XXX: currently we only support IPv6 IPs, not hosts resolving to AAAA records.
# To support them we would have to perform DNS queries to resolve
# the host and check which record it holds (A or AAAA).
if self.is_ipv6():
return TCP6ClientEndpoint(reactor, self.host, self.port)
return TCP4ClientEndpoint(reactor, self.host, self.port)

def is_localhost(self) -> bool:
Expand All @@ -157,7 +195,18 @@ def is_localhost(self) -> bool:
>>> PeerAddress.parse('tcp://foo.bar:444').is_localhost()
False
"""
return self.host in ('127.0.0.1', 'localhost')
return self.host in ('127.0.0.1', 'localhost', '::1')

def is_ipv6(self) -> bool:
"""Used to determine if the entrypoint host is an IPv6 address.
"""
# XXX: This means we don't currently consider DNS names that resolve to IPv6 addresses as IPv6.
return IPV6_REGEX.fullmatch(self.host) is not None

def is_ipv4(self) -> bool:
"""Used to determine if the entrypoint host is an IPv4 address.
"""
return not self.is_ipv6()

def with_id(self, peer_id: PeerId | None = None) -> PeerEndpoint:
"""Create a PeerEndpoint instance with self as the address and with the provided peer_id, or None."""
Expand Down
Loading

0 comments on commit 4da0fb9

Please sign in to comment.