diff --git a/docs/examples.announce_addrs.rst b/docs/examples.announce_addrs.rst new file mode 100644 index 000000000..cd9ae7a12 --- /dev/null +++ b/docs/examples.announce_addrs.rst @@ -0,0 +1,56 @@ +Announce Addresses +================== + +This example demonstrates how to use announce addresses so that a node behind +NAT or a reverse proxy (e.g., ngrok) advertises its publicly reachable address +instead of its local listen address. + +When running a libp2p node behind NAT or a reverse proxy, other nodes cannot +reach it using the internal listen address. By specifying announce addresses, +you can tell peers about your externally accessible addresses instead. + +Usage +----- + +First, ensure you have installed the necessary dependencies from the root of +the repository: + +.. code-block:: console + + $ python -m pip install -e . + +**Node A (listener)** -- start the listener with announce addresses: + +.. code-block:: console + + $ python examples/announce_addrs/announce_addrs.py --listen-port 9001 \ + --announce /dns4/example.ngrok-free.app/tcp/9001 /ip4/1.2.3.4/tcp/4001 + +**Node B (dialer)** -- connect to the listener using its announced address and +peer ID: + +.. code-block:: console + + $ python examples/announce_addrs/announce_addrs.py --listen-port 9002 \ + --dial /dns4/example.ngrok-free.app/tcp/9001/p2p/ + +Notes on NAT and Reverse Proxies +-------------------------------- + +This pattern is useful when: + +- Your node is behind a NAT that performs port forwarding from an external IP + to your local machine. +- You are using a reverse proxy like ngrok that exposes your local port to the + internet. +- You need to advertise different addresses for external vs. internal + connectivity. + +By announcing the correct external addresses, peers will successfully dial your +node regardless of their network position. + +The full source code for this example is below: + +.. literalinclude:: ../examples/announce_addrs/announce_addrs.py + :language: python + :linenos: diff --git a/docs/examples.rst b/docs/examples.rst index 78cd7f43e..b79698b97 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -19,6 +19,7 @@ Examples examples.kademlia examples.mDNS examples.nat + examples.announce_addrs examples.rendezvous examples.random_walk examples.multiple_connections diff --git a/examples/announce_addrs/announce_addrs.py b/examples/announce_addrs/announce_addrs.py new file mode 100644 index 000000000..f45682e6b --- /dev/null +++ b/examples/announce_addrs/announce_addrs.py @@ -0,0 +1,127 @@ +""" +Announce Addresses Example for py-libp2p + +Demonstrates how to use announce addresses so that a node behind NAT +or a reverse proxy (e.g. ngrok) advertises its publicly reachable +address instead of its local listen address. + +Node A (listener): + python announce_addrs.py --listen-port 9001 \ + --announce /dns4/example.ngrok-free.app/tcp/9001 /ip4/1.2.3.4/tcp/4001 + +Node B (dialer): + python announce_addrs.py --listen-port 9002 \ + --dial /dns4/example.ngrok-free.app/tcp/9001/p2p/ +""" + +import argparse +import logging +import secrets + +import multiaddr +import trio + +from libp2p import new_host +from libp2p.crypto.secp256k1 import create_new_key_pair +from libp2p.peer.peerinfo import info_from_p2p_addr + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger("announce_addrs_example") + +# Silence noisy libraries +logging.getLogger("multiaddr").setLevel(logging.WARNING) + + +async def run_listener(port: int, announce_addrs: list[str]) -> None: + """Start a node that listens locally and announces external addresses.""" + key_pair = create_new_key_pair(secrets.token_bytes(32)) + + listen_addrs = [multiaddr.Multiaddr(f"/ip4/0.0.0.0/tcp/{port}")] + + parsed_announce = [multiaddr.Multiaddr(a) for a in announce_addrs] + + host = new_host(key_pair=key_pair, announce_addrs=parsed_announce) + + async with host.run(listen_addrs=listen_addrs): + peer_id = host.get_id().to_string() + + logger.info("Node started") + logger.info(f"Peer ID: {peer_id}") + + logger.info("Transport (local) addresses:") + for addr in host.get_transport_addrs(): + logger.info(f" {addr}") + + logger.info("Announced (public) addresses:") + for addr in host.get_addrs(): + logger.info(f" {addr}") + + print(f"\nPeer ID: {peer_id}") + print("\nTo connect from another node, run:") + for addr in host.get_addrs(): + print(f" python announce_addrs.py --listen-port 9002 --dial {addr}") + + print("\nPress Ctrl+C to exit.") + await trio.sleep_forever() + + +async def run_dialer(port: int, dial_addr: str) -> None: + """Start a node and connect to a remote peer.""" + key_pair = create_new_key_pair(secrets.token_bytes(32)) + + listen_addrs = [multiaddr.Multiaddr(f"/ip4/0.0.0.0/tcp/{port}")] + + host = new_host(key_pair=key_pair) + + async with host.run(listen_addrs=listen_addrs): + logger.info(f"Dialer started, peer ID: {host.get_id().to_string()}") + + ma = multiaddr.Multiaddr(dial_addr) + peer_info = info_from_p2p_addr(ma) + + logger.info(f"Connecting to {peer_info.peer_id}...") + await host.connect(peer_info) + logger.info(f"Successfully connected to {peer_info.peer_id}") + + print(f"\nConnected to peer: {peer_info.peer_id}") + print("Press Ctrl+C to exit.") + await trio.sleep_forever() + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Announce Addresses Example", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--listen-port", + type=int, + default=9001, + help="Local TCP port to listen on (default: 9001)", + ) + parser.add_argument( + "--announce", + nargs="+", + help="Announce addresses (e.g. /dns4/example.ngrok-free.app/tcp/443)", + ) + parser.add_argument( + "--dial", + type=str, + help="Full multiaddr of remote peer to connect (must include /p2p/)", + ) + + args = parser.parse_args() + + if args.dial: + trio.run(run_dialer, args.listen_port, args.dial) + elif args.announce: + trio.run(run_listener, args.listen_port, args.announce) + else: + parser.error("Provide --announce to listen, or --dial to connect to a peer.") + + +if __name__ == "__main__": + main() diff --git a/libp2p/__init__.py b/libp2p/__init__.py index 01d863257..90e57d6f2 100644 --- a/libp2p/__init__.py +++ b/libp2p/__init__.py @@ -479,7 +479,8 @@ def new_host( bootstrap_allow_ipv6: bool = False, bootstrap_dns_timeout: float = 10.0, bootstrap_dns_max_retries: int = 3, - connection_config: ConnectionConfig | None = None + connection_config: ConnectionConfig | None = None, + announce_addrs: Sequence[multiaddr.Multiaddr] | None = None, ) -> IHost: """ Create a new libp2p host based on the given parameters. @@ -505,6 +506,7 @@ def new_host( :param bootstrap_dns_timeout: DNS resolution timeout in seconds per attempt :param bootstrap_dns_max_retries: max DNS resolution retries with backoff :param connection_config: optional connection configuration for connection manager + :param announce_addrs: if set, these replace listen addrs in get_addrs() :return: return a host instance """ @@ -557,6 +559,7 @@ def new_host( bootstrap_allow_ipv6=bootstrap_allow_ipv6, bootstrap_dns_timeout=bootstrap_dns_timeout, bootstrap_dns_max_retries=bootstrap_dns_max_retries, + announce_addrs=announce_addrs, ) return BasicHost( network=swarm, @@ -568,6 +571,7 @@ def new_host( bootstrap_allow_ipv6=bootstrap_allow_ipv6, bootstrap_dns_timeout=bootstrap_dns_timeout, bootstrap_dns_max_retries=bootstrap_dns_max_retries, + announce_addrs=announce_addrs, ) __version__ = __version("libp2p") diff --git a/libp2p/abc.py b/libp2p/abc.py index 101d2be35..ac2f85660 100644 --- a/libp2p/abc.py +++ b/libp2p/abc.py @@ -1993,12 +1993,16 @@ def get_mux(self) -> "Multiselect": @abstractmethod def get_addrs(self) -> list[Multiaddr]: """ - Retrieve all multiaddresses on which the host is listening. + Return the addresses this host advertises to other peers. + + These may differ from the actual listen addresses when + ``announce_addrs`` is configured. Each address includes a + ``/p2p/{peer_id}`` suffix. Returns ------- list[Multiaddr] - A list of multiaddresses. + A list of advertised multiaddresses, each with a ``/p2p/{peer_id}`` suffix. """ diff --git a/libp2p/host/basic_host.py b/libp2p/host/basic_host.py index 95f93050b..2b5adca27 100644 --- a/libp2p/host/basic_host.py +++ b/libp2p/host/basic_host.py @@ -18,6 +18,7 @@ from cryptography import x509 from cryptography.x509.oid import ExtensionOID import multiaddr +from multiaddr.exceptions import ProtocolLookupError import trio import libp2p @@ -93,9 +94,6 @@ background_trio_service, ) from libp2p.transport.quic.connection import QUICConnection -from libp2p.utils.multiaddr_utils import ( - join_multiaddrs, -) import libp2p.utils.paths from libp2p.utils.varint import ( read_length_prefixed_protobuf, @@ -193,6 +191,7 @@ def __init__( bootstrap_allow_ipv6: bool = False, bootstrap_dns_timeout: float = 10.0, bootstrap_dns_max_retries: int = 3, + announce_addrs: Sequence[multiaddr.Multiaddr] | None = None, ) -> None: """ Initialize a BasicHost instance. @@ -208,6 +207,9 @@ def __init__( :param bootstrap_allow_ipv6: If True, bootstrap uses IPv6+TCP when available. :param bootstrap_dns_timeout: DNS resolution timeout in seconds per attempt. :param bootstrap_dns_max_retries: Max DNS resolution retries (with backoff). + :param announce_addrs: Optional addresses to advertise instead of + listen addresses. ``None`` (default) uses listen addresses; + an empty list advertises no addresses. """ self._network = network self._network.set_stream_handler(self._swarm_stream_handler) @@ -253,6 +255,11 @@ def __init__( ) self.psk = psk + # Address announcement configuration + self._announce_addrs = ( + list(announce_addrs) if announce_addrs is not None else None + ) + # Cache a signed-record if the local-node in the PeerStore envelope = create_signed_peer_record( self.get_id(), @@ -349,13 +356,34 @@ def get_transport_addrs(self) -> list[multiaddr.Multiaddr]: def get_addrs(self) -> list[multiaddr.Multiaddr]: """ - Return all the multiaddr addresses this host is listening to. + Return the multiaddr addresses this host advertises to peers. + + If ``announce_addrs`` was provided, those replace listen addresses + entirely. Otherwise listen addresses are used. Note: This method appends the /p2p/{peer_id} suffix to the addresses. Use get_transport_addrs() for raw transport addresses. """ p2p_part = multiaddr.Multiaddr(f"/p2p/{self.get_id()!s}") - return [join_multiaddrs(addr, p2p_part) for addr in self.get_transport_addrs()] + + if self._announce_addrs is not None: + addrs = list(self._announce_addrs) + else: + addrs = self.get_transport_addrs() + + result = [] + for addr in addrs: + # Strip any existing /p2p/ component, then always append our own. + # This avoids identity confusion when announce addrs contain a + # mismatched peer ID (mirrors js-libp2p behaviour). + try: + p2p_value = addr.value_for_protocol("p2p") + except ProtocolLookupError: + p2p_value = None + if p2p_value: + addr = addr.decapsulate(multiaddr.Multiaddr(f"/p2p/{p2p_value}")) + result.append(addr.encapsulate(p2p_part)) + return result def get_connected_peers(self) -> list[ID]: """ @@ -388,7 +416,7 @@ async def _run() -> AsyncIterator[None]: upnp_manager = self.upnp logger.debug("Starting UPnP discovery and port mapping") if await upnp_manager.discover(): - for addr in self.get_addrs(): + for addr in self.get_transport_addrs(): if port := addr.value_for_protocol("tcp"): await upnp_manager.add_port_mapping(int(port), "TCP") if self.bootstrap is not None: @@ -403,7 +431,7 @@ async def _run() -> AsyncIterator[None]: if self.upnp and self.upnp.get_external_ip(): upnp_manager = self.upnp logger.debug("Removing UPnP port mappings") - for addr in self.get_addrs(): + for addr in self.get_transport_addrs(): if port := addr.value_for_protocol("tcp"): await upnp_manager.remove_port_mapping(int(port), "TCP") if self.bootstrap is not None: diff --git a/libp2p/host/routed_host.py b/libp2p/host/routed_host.py index 2a49f62ce..46116b31c 100644 --- a/libp2p/host/routed_host.py +++ b/libp2p/host/routed_host.py @@ -1,5 +1,11 @@ from __future__ import annotations +from collections.abc import ( + Sequence, +) + +import multiaddr + from libp2p.abc import ( INetworkService, IPeerRouting, @@ -40,6 +46,7 @@ def __init__( bootstrap_allow_ipv6: bool = False, bootstrap_dns_timeout: float = 10.0, bootstrap_dns_max_retries: int = 3, + announce_addrs: Sequence[multiaddr.Multiaddr] | None = None, ): """ Initialize a RoutedHost instance. @@ -55,6 +62,7 @@ def __init__( :param bootstrap_allow_ipv6: If True, bootstrap uses IPv6+TCP when available. :param bootstrap_dns_timeout: DNS resolution timeout in seconds per attempt. :param bootstrap_dns_max_retries: Max DNS resolution retries (with backoff). + :param announce_addrs: If set, replace listen addrs in get_addrs() """ super().__init__( network, @@ -66,6 +74,7 @@ def __init__( bootstrap_allow_ipv6=bootstrap_allow_ipv6, bootstrap_dns_timeout=bootstrap_dns_timeout, bootstrap_dns_max_retries=bootstrap_dns_max_retries, + announce_addrs=announce_addrs, ) self._router = router diff --git a/newsfragments/1250.feature.rst b/newsfragments/1250.feature.rst new file mode 100644 index 000000000..2b0156d5c --- /dev/null +++ b/newsfragments/1250.feature.rst @@ -0,0 +1,3 @@ +Added ``announce_addrs`` support to ``BasicHost`` so nodes behind NAT or +reverse proxies can advertise their publicly reachable addresses instead of +local listen addresses. diff --git a/tests/core/host/test_basic_host.py b/tests/core/host/test_basic_host.py index 58ca43829..4b8a30be2 100644 --- a/tests/core/host/test_basic_host.py +++ b/tests/core/host/test_basic_host.py @@ -1,3 +1,4 @@ +from collections.abc import Sequence from unittest.mock import ( AsyncMock, MagicMock, @@ -123,6 +124,90 @@ def test_get_addrs_and_transport_addrs(): ) +def _make_host_with_listener( + announce_addrs: Sequence[Multiaddr] | None = None, +): + """Helper: create a BasicHost with a mocked listener returning a known addr.""" + key_pair = create_new_key_pair() + swarm = new_swarm(key_pair) + host = BasicHost(swarm, announce_addrs=announce_addrs) + mock_transport = MagicMock() + mock_transport.get_addrs.return_value = [Multiaddr("/ip4/127.0.0.1/tcp/8000")] + swarm.listeners = {"tcp": mock_transport} + return host + + +def test_announce_addrs_replaces_listen_addrs(): + announce = [Multiaddr("/ip4/1.2.3.4/tcp/4001")] + host = _make_host_with_listener(announce_addrs=announce) + + addrs = host.get_addrs() + peer_id_str = str(host.get_id()) + + # Should contain only the announce addr, not the listen addr + assert len(addrs) == 1 + addr_str = str(addrs[0]) + assert "/ip4/1.2.3.4/tcp/4001" in addr_str + assert "/ip4/127.0.0.1/tcp/8000" not in addr_str + assert peer_id_str in addr_str + + # get_transport_addrs still returns the real listen addr + transport_addrs = host.get_transport_addrs() + assert str(transport_addrs[0]) == "/ip4/127.0.0.1/tcp/8000" + + +def test_announce_addrs_strips_wrong_peer_id(): + wrong_peer_id = "QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN" + announce = [Multiaddr(f"/ip4/1.2.3.4/tcp/4001/p2p/{wrong_peer_id}")] + host = _make_host_with_listener(announce_addrs=announce) + + addrs = host.get_addrs() + peer_id_str = str(host.get_id()) + + assert len(addrs) == 1 + addr_str = str(addrs[0]) + # Wrong peer id must be stripped and replaced with the host's own + assert wrong_peer_id not in addr_str + assert addr_str == f"/ip4/1.2.3.4/tcp/4001/p2p/{peer_id_str}" + + +def test_announce_addrs_empty_list_advertises_nothing(): + host = _make_host_with_listener(announce_addrs=[]) + + addrs = host.get_addrs() + assert addrs == [] + + +def test_announce_addrs_multiple(): + announce = [ + Multiaddr("/ip4/1.2.3.4/tcp/4001"), + Multiaddr("/ip4/5.6.7.8/tcp/4002"), + ] + host = _make_host_with_listener(announce_addrs=announce) + + addrs = host.get_addrs() + peer_id_str = str(host.get_id()) + + assert len(addrs) == 2 + assert str(addrs[0]) == f"/ip4/1.2.3.4/tcp/4001/p2p/{peer_id_str}" + assert str(addrs[1]) == f"/ip4/5.6.7.8/tcp/4002/p2p/{peer_id_str}" + + +def test_announce_addrs_with_correct_peer_id(): + # First create a host to get its peer ID, then set announce with that ID + host = _make_host_with_listener(announce_addrs=[]) + peer_id_str = str(host.get_id()) + + # Set announce addr that already includes the correct /p2p/ suffix + host._announce_addrs = [Multiaddr(f"/ip4/1.2.3.4/tcp/4001/p2p/{peer_id_str}")] + + addrs = host.get_addrs() + + assert len(addrs) == 1 + # Should still have exactly one /p2p/ component, no duplication + assert str(addrs[0]) == f"/ip4/1.2.3.4/tcp/4001/p2p/{peer_id_str}" + + @pytest.mark.trio async def test_initiate_autotls_procedure_builds_transport_aware_broker_multiaddr( monkeypatch, tmp_path