Skip to content
56 changes: 56 additions & 0 deletions docs/examples.announce_addrs.rst
Original file line number Diff line number Diff line change
@@ -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/<PEER_ID_OF_A>

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:
1 change: 1 addition & 0 deletions docs/examples.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Examples
examples.kademlia
examples.mDNS
examples.nat
examples.announce_addrs
examples.rendezvous
examples.random_walk
examples.multiple_connections
Expand Down
127 changes: 127 additions & 0 deletions examples/announce_addrs/announce_addrs.py
Original file line number Diff line number Diff line change
@@ -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/<PEER_ID_OF_A>
"""

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/<peerID>)",
)

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()
6 changes: 5 additions & 1 deletion libp2p/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
"""

Expand Down Expand Up @@ -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,
Expand All @@ -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")
8 changes: 6 additions & 2 deletions libp2p/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

"""

Expand Down
42 changes: 35 additions & 7 deletions libp2p/host/basic_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions libp2p/host/routed_host.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from __future__ import annotations

from collections.abc import (
Sequence,
)

import multiaddr

from libp2p.abc import (
INetworkService,
IPeerRouting,
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions newsfragments/1250.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading