Skip to content
Open
21 changes: 21 additions & 0 deletions docs/examples.announce_addrs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ This pattern is useful when:
By announcing the correct external addresses, peers will successfully dial your
node regardless of their network position.

Automatic discovery vs. explicit announce addresses
---------------------------------------------------

py-libp2p also ships with an :class:`~libp2p.host.observed_addr_manager.ObservedAddrManager`
that automatically discovers the host's externally observed addresses through
the Identify protocol. Once enough distinct peer groups confirm the same
external address, it is appended to the output of
:meth:`~libp2p.host.basic_host.BasicHost.get_addrs` -- no manual configuration
is required for the common NAT / EC2 case (see issue #1250).

``announce_addrs`` takes priority over observed addresses: when it is set it
acts as a static ``AddrsFactory`` (matching go-libp2p's
``applyAddrsFactory`` behaviour), so only the explicitly announced list is
advertised. Observations are still recorded internally -- for example to feed
:meth:`~libp2p.host.basic_host.BasicHost.get_nat_type` -- but they are not
emitted by ``get_addrs`` when a static list has been provided.

Use ``announce_addrs`` when you already know the exact public address(es) you
want peers to dial (e.g. a reverse proxy hostname such as ngrok). Rely on
automatic observed-address discovery otherwise.

The full source code for this example is below:

.. literalinclude:: ../examples/announce_addrs/announce_addrs.py
Expand Down
22 changes: 22 additions & 0 deletions docs/libp2p.host.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ libp2p.host.exceptions module
:undoc-members:
:show-inheritance:

libp2p.host.observed\_addr\_manager module
-------------------------------------------

Automatic NAT address discovery. Remote peers report the address they see
us on via the Identify protocol; once enough *distinct observer groups*
(``ACTIVATION_THRESHOLD``, currently ``4``) report the same external
address, it is treated as confirmed and appended by
:meth:`libp2p.host.basic_host.BasicHost.get_addrs` so peers learn the
host's real public address (fixes issue #1250 for NAT/EC2 deployments).

Interaction with ``announce_addrs``: when ``announce_addrs`` is passed to
:class:`~libp2p.host.basic_host.BasicHost` it is treated as an explicit
``AddrsFactory`` (mirroring go-libp2p's ``applyAddrsFactory``) and wins
over observed addresses: observations are still **recorded** (for
:meth:`~libp2p.host.basic_host.BasicHost.get_nat_type` and future
AutoNAT consumers) but are **not** advertised via ``get_addrs``.

.. automodule:: libp2p.host.observed_addr_manager
:members:
:undoc-members:
:show-inheritance:

libp2p.host.ping module
-----------------------

Expand Down
106 changes: 96 additions & 10 deletions libp2p/host/basic_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from cryptography import x509
from cryptography.x509.oid import ExtensionOID
import multiaddr
from multiaddr.exceptions import ProtocolLookupError
from multiaddr.exceptions import MultiaddrError, ProtocolLookupError
import trio

import libp2p
Expand Down Expand Up @@ -50,6 +50,10 @@
from libp2p.host.exceptions import (
StreamFailure,
)
from libp2p.host.observed_addr_manager import (
NATDeviceType,
ObservedAddrManager,
)
from libp2p.host.ping import (
ID as PING_PROTOCOL_ID,
)
Expand Down Expand Up @@ -208,8 +212,16 @@ def __init__(
: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.
listen addresses. ``None`` (default) uses listen addresses
augmented with confirmed observed addresses from
:class:`~libp2p.host.observed_addr_manager.ObservedAddrManager`.
An empty list advertises no addresses. When set, this list acts
as a static ``AddrsFactory`` (mirroring go-libp2p's
``applyAddrsFactory``) and wins over observed addresses:
observations are still **recorded** by the manager (for
:meth:`get_nat_type` and future AutoNAT consumers) but are
**not** emitted by :meth:`get_addrs`. See also
:meth:`get_addrs` for the exact composition rules.
"""
self._network = network
self._network.set_stream_handler(self._swarm_stream_handler)
Expand Down Expand Up @@ -255,10 +267,12 @@ def __init__(
)
self.psk = psk

# Address announcement configuration
# Address announcement configuration (from #1268)
self._announce_addrs = (
list(announce_addrs) if announce_addrs is not None else None
)
# Observed-address tracking (from #1284, issue #1250)
self._observed_addr_manager = ObservedAddrManager()

# Cache a signed-record if the local-node in the PeerStore
envelope = create_signed_peer_record(
Expand Down Expand Up @@ -358,18 +372,35 @@ def get_addrs(self) -> list[multiaddr.Multiaddr]:
"""
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.
Behavior (mirrors go-libp2p's ``AddrsFactory`` pipeline):

* If ``announce_addrs`` was provided at construction time, that list
replaces everything — it is treated as a static ``AddrsFactory`` in
go-libp2p terms. Observed (NAT) addresses are **still recorded**
by :class:`~libp2p.host.observed_addr_manager.ObservedAddrManager`
(for ``get_nat_type`` and future AutoNAT consumers) but are not
emitted here, since the caller has explicitly chosen which
addresses to advertise.
* Otherwise the set of raw transport addresses is augmented with
externally observed addresses that have been confirmed by enough
distinct peer groups (see :data:`ACTIVATION_THRESHOLD`), then the
``/p2p/{peer_id}`` suffix is appended to each.

Use :meth:`get_transport_addrs` for the raw transport addresses
without any observed-address augmentation or ``/p2p`` suffix.
"""
p2p_part = multiaddr.Multiaddr(f"/p2p/{self.get_id()!s}")

if self._announce_addrs is not None:
addrs = list(self._announce_addrs)
else:
addrs = self.get_transport_addrs()
addrs = list(self.get_transport_addrs())
seen = {str(a) for a in addrs}
for obs_addr in self._observed_addr_manager.addrs():
key = str(obs_addr)
if key not in seen:
seen.add(key)
addrs.append(obs_addr)

result = []
for addr in addrs:
Expand All @@ -391,6 +422,26 @@ def get_connected_peers(self) -> list[ID]:
"""
return list(self._network.connections.keys())

def get_nat_type(self) -> tuple[NATDeviceType, NATDeviceType]:
"""
Return the classified NAT device type for TCP and UDP transports.

Thin pass-through to
:meth:`libp2p.host.observed_addr_manager.ObservedAddrManager.get_nat_type`,
which infers NAT behaviour from the distribution of externally
observed addresses reported through Identify. Matches go-libp2p's
``host.getNATType()`` algorithm.

.. note::
Experimental API. Intended primarily for AutoNAT / hole-punch
consumers; the return values, thresholds, and method name may
evolve as those subsystems land in py-libp2p.

:return: ``(tcp_nat_type, udp_nat_type)``, each one of
:class:`~libp2p.host.observed_addr_manager.NATDeviceType`.
"""
return self._observed_addr_manager.get_nat_type()

def run(
self,
listen_addrs: Sequence[multiaddr.Multiaddr],
Expand Down Expand Up @@ -1047,6 +1098,40 @@ async def _identify_peer(self, peer_id: ID, *, reason: str) -> None:
identify_msg.ParseFromString(data)
await _update_peerstore_from_identify(self.peerstore, peer_id, identify_msg)
self._identified_peers.add(peer_id)

if identify_msg.HasField("observed_addr") and identify_msg.observed_addr:
try:
our_observed = multiaddr.Multiaddr(identify_msg.observed_addr)
self._observed_addr_manager.record_observation(
swarm_conn, our_observed, self.get_transport_addrs()
)
except MultiaddrError as exc:
# Malformed observed_addr bytes or unknown protocols from a
# misbehaving peer. Expected at low rates; log quietly.
logger.debug(
"ObservedAddrManager: ignoring malformed observed_addr "
"from peer %s: %s",
peer_id,
exc,
)
except ValueError as exc:
logger.debug(
"ObservedAddrManager: ignoring invalid observed_addr "
"value from peer %s: %s",
peer_id,
exc,
)
except Exception as exc:
# Unexpected failure: surface at warning with traceback so
# regressions don't disappear into debug logs.
logger.warning(
"ObservedAddrManager: unexpected failure recording "
"observation from peer %s: %s",
peer_id,
exc,
exc_info=True,
)

logger.debug(
"Identify[%s]: cached %s protocols for peer %s",
reason,
Expand Down Expand Up @@ -1094,6 +1179,7 @@ def _on_notifee_disconnected(self, conn: INetConn) -> None:
if peer_id is None:
return
self._identified_peers.discard(peer_id)
self._observed_addr_manager.remove_conn(conn)

def _get_first_connection(self, peer_id: ID) -> INetConn | None:
connections = self._network.get_connections(peer_id)
Expand Down
Loading
Loading