diff --git a/hathor/builder/builder.py b/hathor/builder/builder.py index ea3afd8f7..b2d7e2890 100644 --- a/hathor/builder/builder.py +++ b/hathor/builder/builder.py @@ -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') @@ -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(), @@ -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 diff --git a/hathor/builder/cli_builder.py b/hathor/builder/cli_builder.py index 464d9b319..a5724de98 100644 --- a/hathor/builder/cli_builder.py +++ b/hathor/builder/cli_builder.py @@ -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( diff --git a/hathor/cli/nginx_config.py b/hathor/cli/nginx_config.py index 5c6f2a874..9f8684f0a 100644 --- a/hathor/cli/nginx_config.py +++ b/hathor/cli/nginx_config.py @@ -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 diff --git a/hathor/cli/run_node.py b/hathor/cli/run_node.py index 9498194ab..58cface89 100644 --- a/hathor/cli/run_node.py +++ b/hathor/cli/run_node.py @@ -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)') @@ -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: @@ -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() @@ -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() @@ -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() @@ -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: diff --git a/hathor/cli/run_node_args.py b/hathor/cli/run_node_args.py index f493a7d33..dca87ed16 100644 --- a/hathor/cli/run_node_args.py +++ b/hathor/cli/run_node_args.py @@ -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 @@ -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 diff --git a/hathor/conf/settings.py b/hathor/conf/settings.py index db235f2b7..3edb664a7 100644 --- a/hathor/conf/settings.py +++ b/hathor/conf/settings.py @@ -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 diff --git a/hathor/manager.py b/hathor/manager.py index cc86dd9dc..8751e5427 100644 --- a/hathor/manager.py +++ b/hathor/manager.py @@ -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: diff --git a/hathor/p2p/manager.py b/hathor/p2p/manager.py index d53c7be83..d7e7045c9 100644 --- a/hathor/p2p/manager.py +++ b/hathor/p2p/manager.py @@ -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 @@ -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. @@ -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 @@ -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( @@ -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 diff --git a/hathor/p2p/peer.py b/hathor/p2p/peer.py index 53f43369d..8bc963b93 100644 --- a/hathor/p2p/peer.py +++ b/hathor/p2p/peer.py @@ -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)) @@ -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 diff --git a/hathor/p2p/peer_endpoint.py b/hathor/p2p/peer_endpoint.py index c7cafce20..62e4624a2 100644 --- a/hathor/p2p/peer_endpoint.py +++ b/hathor/p2p/peer_endpoint.py @@ -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 @@ -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' @@ -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: """ @@ -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: @@ -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.""" diff --git a/hathor/p2p/states/peer_id.py b/hathor/p2p/states/peer_id.py index 77e8a051e..e46e62ce9 100644 --- a/hathor/p2p/states/peer_id.py +++ b/hathor/p2p/states/peer_id.py @@ -42,6 +42,12 @@ def __init__(self, protocol: 'HathorProtocol', settings: HathorSettings) -> None self.my_peer_ready = False self.other_peer_ready = False + # Common capabilities between the two peers + common_capabilities = protocol.capabilities & set(protocol.node.capabilities) + + # whether to relay IPV6 entrypoints + self.should_relay_ipv6_entrypoints: bool = self._settings.CAPABILITY_IPV6 in common_capabilities + def on_enter(self) -> None: self.send_peer_id() @@ -65,10 +71,16 @@ def handle_ready(self, payload: str) -> None: def _get_peer_id_data(self) -> dict[str, Any]: my_peer = self.protocol.my_peer + + if not self.should_relay_ipv6_entrypoints: + entrypoints_as_str = my_peer.info.ipv4_entrypoints_as_str() + else: + entrypoints_as_str = my_peer.info.entrypoints_as_str() + return dict( id=str(my_peer.id), pubKey=my_peer.get_public_key(), - entrypoints=my_peer.info.entrypoints_as_str(), + entrypoints=entrypoints_as_str, ) def send_peer_id(self) -> None: diff --git a/hathor/p2p/states/ready.py b/hathor/p2p/states/ready.py index 1bed1c745..fe1924347 100644 --- a/hathor/p2p/states/ready.py +++ b/hathor/p2p/states/ready.py @@ -96,6 +96,9 @@ def __init__(self, protocol: 'HathorProtocol', settings: HathorSettings) -> None ProtocolMessages.BEST_BLOCKCHAIN: self.handle_best_blockchain, }) + # whether to relay IPV6 entrypoints + self.should_relay_ipv6_entrypoints: bool = self._settings.CAPABILITY_IPV6 in common_capabilities + # Initialize sync manager and add its commands to the list of available commands. connections = self.protocol.connections assert connections is not None @@ -163,8 +166,16 @@ def send_peers(self, peer_list: Iterable[PublicPeer]) -> None: """ data = [] for peer in peer_list: - if peer.info.entrypoints: - data.append(peer.to_unverified_peer().to_json()) + if self.should_relay_ipv6_entrypoints and not peer.info.entrypoints: + self.log.debug('no entrypoints to relay', peer=str(peer.id)) + continue + + if not self.should_relay_ipv6_entrypoints and not peer.info.get_ipv4_only_entrypoints(): + self.log.debug('no ipv4 entrypoints to relay', peer=str(peer.id)) + continue + + data.append(peer.to_unverified_peer().to_json( + only_ipv4_entrypoints=not self.should_relay_ipv6_entrypoints)) self.send_message(ProtocolMessages.PEERS, json_dumps(data)) self.log.debug('send peers', peers=data) diff --git a/hathor/simulator/fake_connection.py b/hathor/simulator/fake_connection.py index b3a29afc9..3c030c901 100644 --- a/hathor/simulator/fake_connection.py +++ b/hathor/simulator/fake_connection.py @@ -19,7 +19,7 @@ from OpenSSL.crypto import X509 from structlog import get_logger -from twisted.internet.address import IPv4Address +from twisted.internet.address import IPv4Address, IPv6Address from twisted.internet.testing import StringTransport from hathor.p2p.peer import PrivatePeer @@ -34,7 +34,7 @@ class HathorStringTransport(StringTransport): - def __init__(self, peer: PrivatePeer, *, peer_address: IPv4Address): + def __init__(self, peer: PrivatePeer, *, peer_address: IPv4Address | IPv6Address): super().__init__(peerAddress=peer_address) self._peer = peer @@ -58,8 +58,8 @@ def __init__( *, latency: float = 0, autoreconnect: bool = False, - addr1: IPv4Address | None = None, - addr2: IPv4Address | None = None, + addr1: IPv4Address | IPv6Address | None = None, + addr2: IPv4Address | IPv6Address | None = None, fake_bootstrap_id: PeerId | None | Literal[False] = False, ): """ diff --git a/tests/cli/test_run_node.py b/tests/cli/test_run_node.py index 3b72a2592..84d73d2ef 100644 --- a/tests/cli/test_run_node.py +++ b/tests/cli/test_run_node.py @@ -20,6 +20,7 @@ def register_signal_handlers(self) -> None: @patch('twisted.internet.reactor.listenTCP') def test_listen_tcp_ipv4(self, mock_listenTCP): + """Should call listenTCP with no interface defined when using only IPv4""" class CustomRunNode(RunNode): def start_manager(self) -> None: pass @@ -31,3 +32,31 @@ def register_signal_handlers(self) -> None: self.assertTrue(run_node is not None) mock_listenTCP.assert_called_with(1234, ANY) + + @patch('twisted.internet.reactor.listenTCP') + def test_listen_tcp_ipv6(self, mock_listenTCP): + """Should call listenTCP with interface='::0' when enabling IPv6""" + class CustomRunNode(RunNode): + def start_manager(self) -> None: + pass + + def register_signal_handlers(self) -> None: + pass + + run_node = CustomRunNode(argv=['--memory-storage', '--x-enable-ipv6', '--status', '1234']) + self.assertTrue(run_node is not None) + + mock_listenTCP.assert_called_with(1234, ANY, interface='::0') + + def test_validate_ipv4_or_ipv6(self): + """The program should exit if no IP version is enabled""" + class CustomRunNode(RunNode): + def start_manager(self) -> None: + pass + + def register_signal_handlers(self) -> None: + pass + + # Should call system exit + with self.assertRaises(SystemExit): + CustomRunNode(argv=['--memory-storage', '--x-disable-ipv4', '--status', '1234']) diff --git a/tests/p2p/test_bootstrap.py b/tests/p2p/test_bootstrap.py index 82aa932bb..9855a0fda 100644 --- a/tests/p2p/test_bootstrap.py +++ b/tests/p2p/test_bootstrap.py @@ -50,7 +50,18 @@ class BootstrapTestCase(unittest.TestCase): def test_mock_discovery(self) -> None: pubsub = PubSubManager(self.clock) peer = PrivatePeer.auto_generated() - connections = ConnectionsManager(self._settings, self.clock, peer, pubsub, True, self.rng, True) + connections = ConnectionsManager( + self._settings, + self.clock, + peer, + pubsub, + True, + self.rng, + True, + enable_ipv6=False, + disable_ipv4=False + ) + host_ports1 = [ ('foobar', 1234), ('127.0.0.99', 9999), @@ -74,7 +85,18 @@ def test_mock_discovery(self) -> None: def test_dns_discovery(self) -> None: pubsub = PubSubManager(self.clock) peer = PrivatePeer.auto_generated() - connections = ConnectionsManager(self._settings, self.clock, peer, pubsub, True, self.rng, True) + connections = ConnectionsManager( + self._settings, + self.clock, + peer, + pubsub, + True, + self.rng, + True, + enable_ipv6=False, + disable_ipv4=False + ) + bootstrap_a = [ '127.0.0.99', '127.0.0.88', diff --git a/tests/p2p/test_connections.py b/tests/p2p/test_connections.py index b27897ca4..db5a85f1e 100644 --- a/tests/p2p/test_connections.py +++ b/tests/p2p/test_connections.py @@ -23,3 +23,69 @@ def test_manager_connections(self) -> None: self.assertIn(endpoint, manager.connections.iter_not_ready_endpoints()) self.assertNotIn(endpoint, manager.connections.iter_ready_connections()) self.assertNotIn(endpoint, manager.connections.iter_all_connections()) + + def test_manager_disabled_ipv6(self) -> None: + """Should not try to connect to ipv6 peers if ipv6 is disabled""" + + manager = self.create_peer( + 'testnet', + enable_sync_v1=False, + enable_sync_v2=True, + enable_ipv6=False, + disable_ipv4=False + ) + + endpoint = PeerEndpoint.parse('tcp://[::1]:8005') + manager.connections.connect_to(endpoint, use_ssl=True) + + self.assertNotIn(endpoint, manager.connections.iter_not_ready_endpoints()) + self.assertNotIn(endpoint, manager.connections.iter_ready_connections()) + self.assertNotIn(endpoint, manager.connections.iter_all_connections()) + + def test_manager_enabled_ipv6_and_ipv4(self) -> None: + """Should connect to both ipv4 and ipv6 peers if both are enabled""" + + manager = self.create_peer( + 'testnet', + enable_sync_v1=False, + enable_sync_v2=True, + enable_ipv6=True, + disable_ipv4=False + ) + + endpoint_ipv6 = PeerEndpoint.parse('tcp://[::3:2:1]:8005') + manager.connections.connect_to(endpoint_ipv6, use_ssl=True) + + endpoint_ipv4 = PeerEndpoint.parse('tcp://1.2.3.4:8005') + manager.connections.connect_to(endpoint_ipv4, use_ssl=True) + + self.assertIn( + endpoint_ipv4.addr.host, + list(map(lambda x: x.addr.host, manager.connections.iter_not_ready_endpoints())) + ) + self.assertIn( + endpoint_ipv6.addr.host, + list(map(lambda x: x.addr.host, manager.connections.iter_not_ready_endpoints())) + ) + + self.assertEqual(2, len(list(manager.connections.iter_not_ready_endpoints()))) + self.assertEqual(0, len(list(manager.connections.iter_ready_connections()))) + self.assertEqual(0, len(list(manager.connections.iter_all_connections()))) + + def test_manager_disabled_ipv4(self) -> None: + """Should not try to connect to ipv4 peers if ipv4 is disabled""" + + manager = self.create_peer( + 'testnet', + enable_sync_v1=False, + enable_sync_v2=True, + enable_ipv6=True, + disable_ipv4=True, + ) + + endpoint = PeerEndpoint.parse('tcp://127.0.0.1:8005') + manager.connections.connect_to(endpoint, use_ssl=True) + + self.assertEqual(0, len(list(manager.connections.iter_not_ready_endpoints()))) + self.assertEqual(0, len(list(manager.connections.iter_ready_connections()))) + self.assertEqual(0, len(list(manager.connections.iter_all_connections()))) diff --git a/tests/p2p/test_entrypoint.py b/tests/p2p/test_entrypoint.py new file mode 100644 index 000000000..718ca4bdc --- /dev/null +++ b/tests/p2p/test_entrypoint.py @@ -0,0 +1,42 @@ +from hathor.p2p.peer_endpoint import PeerAddress, PeerEndpoint, Protocol +from tests import unittest + + +class EntrypointTestCase(unittest.TestCase): + def test_is_ipv6(self) -> None: + valid_addresses = [ + '::', + '::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' + ] + + invalid_addresses = [ + '127.0.0.1', + '1200::AB00:1234::2552:7777:1313', + '2001:db8::g123', + '2001:db8::85a3::7334', + '2001:db8:85a3:0000:0000:8a2e:0370:7334:1234', + '12345::abcd', + '2001:db8:85a3:8a2e:0370', + '2001:db8:85a3::8a2e:3707334', + '1234:56789::abcd', + ':2001:db8::1', + '2001:db8::1:', + '2001::85a3::8a2e:370:7334' + ] + + for address in valid_addresses: + peer_address = PeerAddress(Protocol.TCP, address, 40403) + self.assertTrue(PeerEndpoint(peer_address).addr.is_ipv6()) + + for address in invalid_addresses: + peer_address = PeerAddress(Protocol.TCP, address, 40403) + self.assertFalse(PeerEndpoint(peer_address).addr.is_ipv6()) diff --git a/tests/p2p/test_peer_id.py b/tests/p2p/test_peer_id.py index 56dfaf79b..1f95cbd12 100644 --- a/tests/p2p/test_peer_id.py +++ b/tests/p2p/test_peer_id.py @@ -276,6 +276,10 @@ async def test_validate_entrypoint(self) -> None: peer.info.entrypoints = [PeerAddress.parse('tcp://uri_name:40403')] result = await peer.info.validate_entrypoint(protocol) self.assertTrue(result) + # if entrypoint is an IPv6 + peer.entrypoints = [PeerEndpoint.parse('tcp://[::1]:40403')] + result = await peer.info.validate_entrypoint(protocol) + self.assertTrue(result) # test invalid. DNS in test mode will resolve to '127.0.0.1:40403' protocol.entrypoint = PeerEndpoint.parse('tcp://45.45.45.45:40403') result = await peer.info.validate_entrypoint(protocol) @@ -298,6 +302,10 @@ def getPeer(self) -> DummyPeer: peer.info.entrypoints = [PeerAddress.parse('tcp://uri_name:40403')] result = await peer.info.validate_entrypoint(protocol) self.assertTrue(result) + # if entrypoint is an IPv6 + peer.entrypoints = [PeerEndpoint.parse('tcp://[2001:db8::ff00:42:8329]:40403')] + result = await peer.info.validate_entrypoint(protocol) + self.assertTrue(result) class SyncV1PeerIdTest(unittest.SyncV1Params, BasePeerIdTest): diff --git a/tests/p2p/test_protocol.py b/tests/p2p/test_protocol.py index 841a45929..708af1f0d 100644 --- a/tests/p2p/test_protocol.py +++ b/tests/p2p/test_protocol.py @@ -3,6 +3,7 @@ from unittest.mock import Mock, patch from twisted.internet import defer +from twisted.internet.address import IPv4Address from twisted.internet.protocol import Protocol from twisted.python.failure import Failure @@ -10,7 +11,7 @@ from hathor.p2p.manager import ConnectionsManager from hathor.p2p.messages import ProtocolMessages from hathor.p2p.peer import PrivatePeer -from hathor.p2p.peer_endpoint import PeerAddress +from hathor.p2p.peer_endpoint import PeerAddress, PeerEndpoint from hathor.p2p.protocol import HathorLineReceiver, HathorProtocol from hathor.simulator import FakeConnection from hathor.util import json_dumps, json_loadb @@ -201,6 +202,87 @@ def test_valid_hello(self) -> None: self.assertFalse(self.conn.tr1.disconnecting) self.assertFalse(self.conn.tr2.disconnecting) + def test_hello_without_ipv6_capability(self) -> None: + """Tests the connection between peers with and without the IPV6 capability. + Expected behavior: the entrypoint with IPV6 is not relayed. + """ + network = 'testnet' + manager1 = self.create_peer( + network, + peer=self.peer1, + capabilities=[self._settings.CAPABILITY_IPV6, self._settings.CAPABILITY_SYNC_VERSION] + ) + manager2 = self.create_peer( + network, + peer=self.peer2, + capabilities=[self._settings.CAPABILITY_SYNC_VERSION] + ) + + port1 = FakeConnection._get_port(manager1) + port2 = FakeConnection._get_port(manager2) + + addr1 = IPv4Address('TCP', '192.168.1.1', port1) + addr2 = IPv4Address('TCP', '192.168.1.1', port2) + + entrypoint_1_ipv6 = PeerEndpoint.parse('tcp://[::1]:54321') + entrypoint_1_ipv4 = PeerEndpoint.parse(f'tcp://192.168.1.1:{port1}') + entrypoint_2_ipv4 = PeerEndpoint.parse(f'tcp://192.168.1.1:{port2}') + + self.peer1.info.entrypoints.append(entrypoint_1_ipv6.addr) + self.peer1.info.entrypoints.append(entrypoint_1_ipv4.addr) + self.peer2.info.entrypoints.append(entrypoint_2_ipv4.addr) + + conn = FakeConnection(manager1, manager2, addr1=addr1, addr2=addr2) + + conn.run_one_step() # HELLO + conn.run_one_step() # PEER-ID + + self.assertEqual(len(conn.proto1.peer.info.entrypoints), 1) + self.assertEqual(len(conn.proto2.peer.info.entrypoints), 1) + self.assertEqual(conn.proto1.peer.info.entrypoints[0].host, '192.168.1.1') + self.assertEqual(conn.proto2.peer.info.entrypoints[0].host, '192.168.1.1') + + def test_hello_with_ipv6_capability(self) -> None: + """Tests the connection between peers with the IPV6 capability. + Expected behavior: the entrypoint with IPV6 is relayed. + """ + network = 'testnet' + manager1 = self.create_peer( + network, + peer=self.peer1, + capabilities=[self._settings.CAPABILITY_IPV6, self._settings.CAPABILITY_SYNC_VERSION] + ) + manager2 = self.create_peer( + network, + peer=self.peer2, + capabilities=[self._settings.CAPABILITY_IPV6, self._settings.CAPABILITY_SYNC_VERSION] + ) + + port1 = FakeConnection._get_port(manager1) + port2 = FakeConnection._get_port(manager2) + + addr1 = IPv4Address('TCP', '192.168.1.1', port1) + addr2 = IPv4Address('TCP', '192.168.1.1', port2) + + entrypoint_1_ipv6 = PeerEndpoint.parse('tcp://[::1]:54321') + entrypoint_1_ipv4 = PeerEndpoint.parse(f'tcp://192.168.1.1:{port1}') + entrypoint_2_ipv4 = PeerEndpoint.parse(f'tcp://192.168.1.1:{port2}') + + self.peer1.info.entrypoints.append(entrypoint_1_ipv6.addr) + self.peer1.info.entrypoints.append(entrypoint_1_ipv4.addr) + self.peer2.info.entrypoints.append(entrypoint_2_ipv4.addr) + + conn = FakeConnection(manager1, manager2, addr1=addr1, addr2=addr2) + + conn.run_one_step() # HELLO + conn.run_one_step() # PEER-ID + + self.assertEqual(len(conn.proto1.peer.info.entrypoints), 1) + self.assertEqual(len(conn.proto2.peer.info.entrypoints), 2) + self.assertTrue('::1' in map(lambda x: x.host, conn.proto2.peer.info.entrypoints)) + self.assertTrue('192.168.1.1' in map(lambda x: x.host, conn.proto2.peer.info.entrypoints)) + self.assertEqual(conn.proto1.peer.info.entrypoints[0].host, '192.168.1.1') + def test_invalid_same_peer_id(self) -> None: manager3 = self.create_peer(self.network, peer=self.peer1) conn = FakeConnection(self.manager1, manager3) diff --git a/tests/unittest.py b/tests/unittest.py index 94cef1c34..4a659f6bd 100644 --- a/tests/unittest.py +++ b/tests/unittest.py @@ -226,7 +226,9 @@ def create_peer( # type: ignore[no-untyped-def] pubsub: PubSubManager | None = None, event_storage: EventStorage | None = None, enable_event_queue: bool | None = None, - use_memory_storage: bool | None = None + use_memory_storage: bool | None = None, + enable_ipv6: bool = False, + disable_ipv4: bool = False, ): # TODO: Add -> HathorManager here. It breaks the lint in a lot of places. enable_sync_v1, enable_sync_v2 = self._syncVersionFlags(enable_sync_v1, enable_sync_v2) @@ -290,6 +292,15 @@ def create_peer( # type: ignore[no-untyped-def] if utxo_index: builder.enable_utxo_index() + if capabilities is not None: + builder.set_capabilities(capabilities) + + if enable_ipv6: + builder.enable_ipv6() + + if disable_ipv4: + builder.disable_ipv4() + daa = DifficultyAdjustmentAlgorithm(settings=self._settings, test_mode=TestMode.TEST_ALL_WEIGHT) builder.set_daa(daa) manager = self.create_peer_from_builder(builder, start_manager=start_manager)