diff --git a/docs/libp2p.discovery.bootstrap.rst b/docs/libp2p.discovery.bootstrap.rst new file mode 100644 index 00000000..d99e80d9 --- /dev/null +++ b/docs/libp2p.discovery.bootstrap.rst @@ -0,0 +1,13 @@ +libp2p.discovery.bootstrap package +================================== + +Submodules +---------- + +Module contents +--------------- + +.. automodule:: libp2p.discovery.bootstrap + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/libp2p.discovery.rst b/docs/libp2p.discovery.rst index cb8859a4..508ca059 100644 --- a/docs/libp2p.discovery.rst +++ b/docs/libp2p.discovery.rst @@ -7,6 +7,7 @@ Subpackages .. toctree:: :maxdepth: 4 + libp2p.discovery.bootstrap libp2p.discovery.events libp2p.discovery.mdns diff --git a/examples/bootstrap/bootstrap.py b/examples/bootstrap/bootstrap.py new file mode 100644 index 00000000..af7d08cc --- /dev/null +++ b/examples/bootstrap/bootstrap.py @@ -0,0 +1,136 @@ +import argparse +import logging +import secrets + +import multiaddr +import trio + +from libp2p import new_host +from libp2p.abc import PeerInfo +from libp2p.crypto.secp256k1 import create_new_key_pair +from libp2p.discovery.events.peerDiscovery import peerDiscovery + +# Configure logging +logger = logging.getLogger("libp2p.discovery.bootstrap") +logger.setLevel(logging.INFO) +handler = logging.StreamHandler() +handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) +logger.addHandler(handler) + +# Configure root logger to only show warnings and above to reduce noise +# This prevents verbose DEBUG messages from multiaddr, DNS, etc. +logging.getLogger().setLevel(logging.WARNING) + +# Specifically silence noisy libraries +logging.getLogger("multiaddr").setLevel(logging.WARNING) +logging.getLogger("root").setLevel(logging.WARNING) + + +def on_peer_discovery(peer_info: PeerInfo) -> None: + """Handler for peer discovery events.""" + logger.info(f"🔍 Discovered peer: {peer_info.peer_id}") + logger.debug(f" Addresses: {[str(addr) for addr in peer_info.addrs]}") + + +# Example bootstrap peers +BOOTSTRAP_PEERS = [ + "/dnsaddr/github.com/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", + "/dnsaddr/cloudflare.com/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", + "/dnsaddr/google.com/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", + "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", + "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", + "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", + "/ip6/2604:a880:1:20::203:d001/tcp/4001/p2p/QmSoLPppuBtQSGwKDZT2M73ULpjvfd3aZ6ha4oFGL1KrGM", + "/ip4/128.199.219.111/tcp/4001/p2p/QmSoLV4Bbm51jM9C4gDYZQ9Cy3U6aXMJDAbzgu2fzaDs64", + "/ip4/104.236.76.40/tcp/4001/p2p/QmSoLV4Bbm51jM9C4gDYZQ9Cy3U6aXMJDAbzgu2fzaDs64", + "/ip4/178.62.158.247/tcp/4001/p2p/QmSoLer265NRgSp2LA3dPaeykiS1J6DifTC88f5uVQKNAd", + "/ip6/2604:a880:1:20::203:d001/tcp/4001/p2p/QmSoLPppuBtQSGwKDZT2M73ULpjvfd3aZ6ha4oFGL1KrGM", + "/ip6/2400:6180:0:d0::151:6001/tcp/4001/p2p/QmSoLSafTMBsPKadTEgaXctDQVcqN88CNLHXMkTNwMKPnu", + "/ip6/2a03:b0c0:0:1010::23:1001/tcp/4001/p2p/QmSoLueR4xBeUbY9WZ9xGUUxunbKWcrNFTDAadQJmocnWm", +] + + +async def run(port: int, bootstrap_addrs: list[str]) -> None: + """Run the bootstrap discovery example.""" + # Generate key pair + secret = secrets.token_bytes(32) + key_pair = create_new_key_pair(secret) + + # Create listen address + listen_addr = multiaddr.Multiaddr(f"/ip4/0.0.0.0/tcp/{port}") + + # Register peer discovery handler + peerDiscovery.register_peer_discovered_handler(on_peer_discovery) + + logger.info("🚀 Starting Bootstrap Discovery Example") + logger.info(f"📍 Listening on: {listen_addr}") + logger.info(f"🌐 Bootstrap peers: {len(bootstrap_addrs)}") + + print("\n" + "=" * 60) + print("Bootstrap Discovery Example") + print("=" * 60) + print("This example demonstrates connecting to bootstrap peers.") + print("Watch the logs for peer discovery events!") + print("Press Ctrl+C to exit.") + print("=" * 60) + + # Create and run host with bootstrap discovery + host = new_host(key_pair=key_pair, bootstrap=bootstrap_addrs) + + try: + async with host.run(listen_addrs=[listen_addr]): + # Keep running and log peer discovery events + await trio.sleep_forever() + except KeyboardInterrupt: + logger.info("👋 Shutting down...") + + +def main() -> None: + """Main entry point.""" + description = """ + Bootstrap Discovery Example for py-libp2p + + This example demonstrates how to use bootstrap peers for peer discovery. + Bootstrap peers are predefined peers that help new nodes join the network. + + Usage: + python bootstrap.py -p 8000 + python bootstrap.py -p 8001 --custom-bootstrap \\ + "/ip4/127.0.0.1/tcp/8000/p2p/QmYourPeerID" + """ + + parser = argparse.ArgumentParser( + description=description, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "-p", "--port", default=0, type=int, help="Port to listen on (default: random)" + ) + parser.add_argument( + "--custom-bootstrap", + nargs="*", + help="Custom bootstrap addresses (space-separated)", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose output" + ) + + args = parser.parse_args() + + if args.verbose: + logger.setLevel(logging.DEBUG) + + # Use custom bootstrap addresses if provided, otherwise use defaults + bootstrap_addrs = ( + args.custom_bootstrap if args.custom_bootstrap else BOOTSTRAP_PEERS + ) + + try: + trio.run(run, args.port, bootstrap_addrs) + except KeyboardInterrupt: + logger.info("Exiting...") + + +if __name__ == "__main__": + main() diff --git a/libp2p/__init__.py b/libp2p/__init__.py index 542a71c1..d2ce122a 100644 --- a/libp2p/__init__.py +++ b/libp2p/__init__.py @@ -251,6 +251,7 @@ def new_host( muxer_preference: Literal["YAMUX", "MPLEX"] | None = None, listen_addrs: Sequence[multiaddr.Multiaddr] | None = None, enable_mDNS: bool = False, + bootstrap: list[str] | None = None, negotiate_timeout: int = DEFAULT_NEGOTIATE_TIMEOUT, ) -> IHost: """ @@ -264,6 +265,7 @@ def new_host( :param muxer_preference: optional explicit muxer preference :param listen_addrs: optional list of multiaddrs to listen on :param enable_mDNS: whether to enable mDNS discovery + :param bootstrap: optional list of bootstrap peer addresses as strings :return: return a host instance """ swarm = new_swarm( @@ -276,7 +278,7 @@ def new_host( ) if disc_opt is not None: - return RoutedHost(swarm, disc_opt, enable_mDNS) - return BasicHost(network=swarm,enable_mDNS=enable_mDNS , negotitate_timeout=negotiate_timeout) + return RoutedHost(swarm, disc_opt, enable_mDNS, bootstrap) + return BasicHost(network=swarm,enable_mDNS=enable_mDNS , bootstrap=bootstrap, negotitate_timeout=negotiate_timeout) __version__ = __version("libp2p") diff --git a/libp2p/discovery/bootstrap/__init__.py b/libp2p/discovery/bootstrap/__init__.py new file mode 100644 index 00000000..bad6ff74 --- /dev/null +++ b/libp2p/discovery/bootstrap/__init__.py @@ -0,0 +1,5 @@ +"""Bootstrap peer discovery module for py-libp2p.""" + +from .bootstrap import BootstrapDiscovery + +__all__ = ["BootstrapDiscovery"] diff --git a/libp2p/discovery/bootstrap/bootstrap.py b/libp2p/discovery/bootstrap/bootstrap.py new file mode 100644 index 00000000..222a88a1 --- /dev/null +++ b/libp2p/discovery/bootstrap/bootstrap.py @@ -0,0 +1,94 @@ +import logging + +from multiaddr import Multiaddr +from multiaddr.resolvers import DNSResolver + +from libp2p.abc import ID, INetworkService, PeerInfo +from libp2p.discovery.bootstrap.utils import validate_bootstrap_addresses +from libp2p.discovery.events.peerDiscovery import peerDiscovery +from libp2p.peer.peerinfo import info_from_p2p_addr + +logger = logging.getLogger("libp2p.discovery.bootstrap") +resolver = DNSResolver() + + +class BootstrapDiscovery: + """ + Bootstrap-based peer discovery for py-libp2p. + Connects to predefined bootstrap peers and adds them to peerstore. + """ + + def __init__(self, swarm: INetworkService, bootstrap_addrs: list[str]): + self.swarm = swarm + self.peerstore = swarm.peerstore + self.bootstrap_addrs = bootstrap_addrs or [] + self.discovered_peers: set[str] = set() + + async def start(self) -> None: + """Process bootstrap addresses and emit peer discovery events.""" + logger.debug( + f"Starting bootstrap discovery with " + f"{len(self.bootstrap_addrs)} bootstrap addresses" + ) + + # Validate and filter bootstrap addresses + self.bootstrap_addrs = validate_bootstrap_addresses(self.bootstrap_addrs) + + for addr_str in self.bootstrap_addrs: + try: + await self._process_bootstrap_addr(addr_str) + except Exception as e: + logger.debug(f"Failed to process bootstrap address {addr_str}: {e}") + + def stop(self) -> None: + """Clean up bootstrap discovery resources.""" + logger.debug("Stopping bootstrap discovery") + self.discovered_peers.clear() + + async def _process_bootstrap_addr(self, addr_str: str) -> None: + """Convert string address to PeerInfo and add to peerstore.""" + try: + multiaddr = Multiaddr(addr_str) + except Exception as e: + logger.debug(f"Invalid multiaddr format '{addr_str}': {e}") + return + if self.is_dns_addr(multiaddr): + resolved_addrs = await resolver.resolve(multiaddr) + peer_id_str = multiaddr.get_peer_id() + if peer_id_str is None: + logger.warning(f"Missing peer ID in DNS address: {addr_str}") + return + peer_id = ID.from_base58(peer_id_str) + addrs = [addr for addr in resolved_addrs] + if not addrs: + logger.warning(f"No addresses resolved for DNS address: {addr_str}") + return + peer_info = PeerInfo(peer_id, addrs) + self.add_addr(peer_info) + else: + self.add_addr(info_from_p2p_addr(multiaddr)) + + def is_dns_addr(self, addr: Multiaddr) -> bool: + """Check if the address is a DNS address.""" + return any(protocol.name == "dnsaddr" for protocol in addr.protocols()) + + def add_addr(self, peer_info: PeerInfo) -> None: + """Add a peer to the peerstore and emit discovery event.""" + # Skip if it's our own peer + if peer_info.peer_id == self.swarm.get_peer_id(): + logger.debug(f"Skipping own peer ID: {peer_info.peer_id}") + return + + # Always add addresses to peerstore (allows multiple addresses for same peer) + self.peerstore.add_addrs(peer_info.peer_id, peer_info.addrs, 10) + + # Only emit discovery event if this is the first time we see this peer + peer_id_str = str(peer_info.peer_id) + if peer_id_str not in self.discovered_peers: + # Track discovered peer + self.discovered_peers.add(peer_id_str) + # Emit peer discovery event + peerDiscovery.emit_peer_discovered(peer_info) + logger.debug(f"Peer discovered: {peer_info.peer_id}") + else: + logger.debug(f"Additional addresses added for peer: {peer_info.peer_id}") diff --git a/libp2p/discovery/bootstrap/utils.py b/libp2p/discovery/bootstrap/utils.py new file mode 100644 index 00000000..c88dfd87 --- /dev/null +++ b/libp2p/discovery/bootstrap/utils.py @@ -0,0 +1,51 @@ +"""Utility functions for bootstrap discovery.""" + +import logging + +from multiaddr import Multiaddr + +from libp2p.peer.peerinfo import InvalidAddrError, PeerInfo, info_from_p2p_addr + +logger = logging.getLogger("libp2p.discovery.bootstrap.utils") + + +def validate_bootstrap_addresses(addrs: list[str]) -> list[str]: + """ + Validate and filter bootstrap addresses. + + :param addrs: List of bootstrap address strings + :return: List of valid bootstrap addresses + """ + valid_addrs = [] + + for addr_str in addrs: + try: + # Try to parse as multiaddr + multiaddr = Multiaddr(addr_str) + + # Try to extract peer info (this validates the p2p component) + info_from_p2p_addr(multiaddr) + + valid_addrs.append(addr_str) + logger.debug(f"Valid bootstrap address: {addr_str}") + + except (InvalidAddrError, ValueError, Exception) as e: + logger.warning(f"Invalid bootstrap address '{addr_str}': {e}") + continue + + return valid_addrs + + +def parse_bootstrap_peer_info(addr_str: str) -> PeerInfo | None: + """ + Parse bootstrap address string into PeerInfo. + + :param addr_str: Bootstrap address string + :return: PeerInfo object or None if parsing fails + """ + try: + multiaddr = Multiaddr(addr_str) + return info_from_p2p_addr(multiaddr) + except Exception as e: + logger.error(f"Failed to parse bootstrap address '{addr_str}': {e}") + return None diff --git a/libp2p/host/basic_host.py b/libp2p/host/basic_host.py index cc93be08..70e41953 100644 --- a/libp2p/host/basic_host.py +++ b/libp2p/host/basic_host.py @@ -29,6 +29,7 @@ from libp2p.custom_types import ( StreamHandlerFn, TProtocol, ) +from libp2p.discovery.bootstrap.bootstrap import BootstrapDiscovery from libp2p.discovery.mdns.mdns import MDNSDiscovery from libp2p.host.defaults import ( get_default_protocols, @@ -92,6 +93,7 @@ class BasicHost(IHost): self, network: INetworkService, enable_mDNS: bool = False, + bootstrap: list[str] | None = None, default_protocols: Optional["OrderedDict[TProtocol, StreamHandlerFn]"] = None, negotitate_timeout: int = DEFAULT_NEGOTIATE_TIMEOUT, ) -> None: @@ -105,6 +107,8 @@ class BasicHost(IHost): self.multiselect_client = MultiselectClient() if enable_mDNS: self.mDNS = MDNSDiscovery(network) + if bootstrap: + self.bootstrap = BootstrapDiscovery(network, bootstrap) def get_id(self) -> ID: """ @@ -172,11 +176,16 @@ class BasicHost(IHost): if hasattr(self, "mDNS") and self.mDNS is not None: logger.debug("Starting mDNS Discovery") self.mDNS.start() + if hasattr(self, "bootstrap") and self.bootstrap is not None: + logger.debug("Starting Bootstrap Discovery") + await self.bootstrap.start() try: yield finally: if hasattr(self, "mDNS") and self.mDNS is not None: self.mDNS.stop() + if hasattr(self, "bootstrap") and self.bootstrap is not None: + self.bootstrap.stop() return _run() diff --git a/libp2p/host/routed_host.py b/libp2p/host/routed_host.py index 166a15ec..e103c9e5 100644 --- a/libp2p/host/routed_host.py +++ b/libp2p/host/routed_host.py @@ -19,9 +19,13 @@ class RoutedHost(BasicHost): _router: IPeerRouting def __init__( - self, network: INetworkService, router: IPeerRouting, enable_mDNS: bool = False + self, + network: INetworkService, + router: IPeerRouting, + enable_mDNS: bool = False, + bootstrap: list[str] | None = None, ): - super().__init__(network, enable_mDNS) + super().__init__(network, enable_mDNS, bootstrap) self._router = router async def connect(self, peer_info: PeerInfo) -> None: diff --git a/newsfragments/711.feature.rst b/newsfragments/711.feature.rst new file mode 100644 index 00000000..a4c4c5ff --- /dev/null +++ b/newsfragments/711.feature.rst @@ -0,0 +1 @@ +Added `Bootstrap` peer discovery module that allows nodes to connect to predefined bootstrap peers for network discovery. diff --git a/tests/discovery/__init__.py b/tests/discovery/__init__.py index e69de29b..297d7bd2 100644 --- a/tests/discovery/__init__.py +++ b/tests/discovery/__init__.py @@ -0,0 +1 @@ +"""Discovery tests for py-libp2p.""" diff --git a/tests/discovery/bootstrap/__init__.py b/tests/discovery/bootstrap/__init__.py new file mode 100644 index 00000000..4bb10e8a --- /dev/null +++ b/tests/discovery/bootstrap/__init__.py @@ -0,0 +1 @@ +"""Bootstrap discovery tests for py-libp2p.""" diff --git a/tests/discovery/bootstrap/test_integration.py b/tests/discovery/bootstrap/test_integration.py new file mode 100644 index 00000000..06fba0f6 --- /dev/null +++ b/tests/discovery/bootstrap/test_integration.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Test the full bootstrap discovery integration +""" + +import secrets + +import pytest + +from libp2p import new_host +from libp2p.crypto.secp256k1 import create_new_key_pair +from libp2p.host.basic_host import BasicHost + + +@pytest.mark.trio +async def test_bootstrap_integration(): + """Test bootstrap integration with new_host""" + # Test bootstrap addresses + bootstrap_addrs = [ + "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SznbYGzPwp8qDrq", + "/ip4/104.236.179.241/tcp/4001/p2p/QmSoLPppuBtQSGwKDZT2M73ULpjvfd3aZ6ha4oFGL1KrGM", + ] + + # Generate key pair + secret = secrets.token_bytes(32) + key_pair = create_new_key_pair(secret) + + # Create host with bootstrap + host = new_host(key_pair=key_pair, bootstrap=bootstrap_addrs) + + # Verify bootstrap discovery is set up (cast to BasicHost for type checking) + assert isinstance(host, BasicHost), "Host should be a BasicHost instance" + assert hasattr(host, "bootstrap"), "Host should have bootstrap attribute" + assert host.bootstrap is not None, "Bootstrap discovery should be initialized" + assert len(host.bootstrap.bootstrap_addrs) == len(bootstrap_addrs), ( + "Bootstrap addresses should match" + ) + + +def test_bootstrap_no_addresses(): + """Test that bootstrap is not initialized when no addresses provided""" + secret = secrets.token_bytes(32) + key_pair = create_new_key_pair(secret) + + # Create host without bootstrap + host = new_host(key_pair=key_pair) + + # Verify bootstrap is not initialized + assert isinstance(host, BasicHost) + assert not hasattr(host, "bootstrap") or host.bootstrap is None, ( + "Bootstrap should not be initialized when no addresses provided" + ) diff --git a/tests/discovery/bootstrap/test_utils.py b/tests/discovery/bootstrap/test_utils.py new file mode 100644 index 00000000..b99e948f --- /dev/null +++ b/tests/discovery/bootstrap/test_utils.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +""" +Test bootstrap address validation +""" + +from libp2p.discovery.bootstrap.utils import ( + parse_bootstrap_peer_info, + validate_bootstrap_addresses, +) + + +def test_validate_addresses(): + """Test validation with a mix of valid and invalid addresses in one list.""" + addresses = [ + # Valid - using proper peer IDs + "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", + "/ip4/104.236.179.241/tcp/4001/p2p/QmSoLPppuBtQSGwKDZT2M73ULpjvfd3aZ6ha4oFGL1KrGM", + # Invalid + "invalid-address", + "/ip4/192.168.1.1/tcp/4001", # Missing p2p part + "", # Empty + "/ip4/127.0.0.1/tcp/4001/p2p/InvalidPeerID", # Bad peer ID + ] + valid_expected = [ + "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", + "/ip4/104.236.179.241/tcp/4001/p2p/QmSoLPppuBtQSGwKDZT2M73ULpjvfd3aZ6ha4oFGL1KrGM", + ] + validated = validate_bootstrap_addresses(addresses) + assert validated == valid_expected, ( + f"Expected only valid addresses, got: {validated}" + ) + for addr in addresses: + peer_info = parse_bootstrap_peer_info(addr) + if addr in valid_expected: + assert peer_info is not None and peer_info.peer_id is not None, ( + f"Should parse valid address: {addr}" + ) + else: + assert peer_info is None, f"Should not parse invalid address: {addr}"