Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
43 changes: 43 additions & 0 deletions examples/announce_addrs/README.md
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A better, more discoverable place for this file would be to reformat and move it to examples.announce_addrs.rst. That way it would get included in the docs. You can follow other examples there and literalinclude the example code at the bottom. See examples.chat.rst as an example.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Announce Addresses Example

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.

## Overview

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:

```sh
pip install -e .
```

### Node A (listener)

Start the listener with announce addresses:

```sh
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:

```sh
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're 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.
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 @@ -1994,12 +1994,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
20 changes: 15 additions & 5 deletions libp2p/host/basic_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,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 +190,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 Down Expand Up @@ -253,6 +251,9 @@ def __init__(
)
self.psk = psk

# Address announcement configuration
self._announce_addrs = list(announce_addrs) if announce_addrs else None
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would there be a case where a user would want to announce 0 addresses? If so, we'd need to check if announce_addrs is not None vs if announce_addrs to get that behavior. An empty sequence would result in fallback behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why user would like to announce nothing..

But if user wants to do it anyway, this will be the cases.

announce_addrs is None => fallback to transport_addrs
announce_addrs is [] => get_addrs return []
announce_addrs not any of the above => return announce_addrs


# 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 +350,22 @@ 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()

return [addr.encapsulate(p2p_part) for addr in addrs]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't check before encapsulating, so we could end up with an invalid double-suffixed addr if the user provides a full p2p mulitaddr. As a minimal example:

key_pair = create_new_key_pair()
swarm = new_swarm(key_pair)
host = BasicHost(swarm)
peer_id_str = str(host.get_id())

host_with_p2p_announce = BasicHost(
    swarm,
    announce_addrs=[Multiaddr(f"/ip4/1.2.3.4/tcp/4001/p2p/{peer_id_str}")],
)

assert (
    str(host_with_p2p_announce.get_addrs()[0]).count(f"/p2p/{peer_id_str}") == 2


def get_connected_peers(self) -> list[ID]:
"""
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/1268.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.
33 changes: 33 additions & 0 deletions tests/core/host/test_basic_host.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import Sequence
from unittest.mock import (
AsyncMock,
MagicMock,
Expand Down Expand Up @@ -94,6 +95,38 @@ 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"


@pytest.mark.trio
async def test_initiate_autotls_procedure_builds_transport_aware_broker_multiaddr(
monkeypatch, tmp_path
Expand Down
Loading