Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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