Skip to content

proxystore.p2p.nat

Tools for checking NAT mapping behavior using STUN.

This module implements the mapping behavior discovery procedure of RFC 5780 using RFC 5389 binding requests.

The classic NAT taxonomy of RFC 3489 (full-cone, restricted-cone, and so on) was deprecated because real NATs do not fall into those categories: mapping behavior and filtering behavior are independent. Only mapping behavior affects whether NAT traversal works. A NAT which assigns the same external address regardless of the destination (endpoint-independent mapping) can be traversed by hole-punching, even when it filters unsolicited traffic, because the relay server coordinates both peers to send simultaneously. A NAT which assigns a different external address per destination (address-dependent mapping, historically "symmetric") cannot, because the address a peer learns is not the address it must send to.

Determining filtering behavior would require the RFC 3489 CHANGE-REQUEST attribute, which needs a STUN server listening on two IP addresses. Such servers are increasingly rare, and the answer would not change the advice given here, so this module does not use them.

NatMapping

Bases: Enum

How a NAT assigns external addresses to outbound flows.

NoNat class-attribute instance-attribute

NoNat = 'No NAT'

Host is not behind a NAT and is directly reachable.

EndpointIndependent class-attribute instance-attribute

EndpointIndependent = 'Endpoint-independent mapping'

Host is behind a NAT which reuses one external address for all peers.

AddressDependent class-attribute instance-attribute

AddressDependent = 'Address-dependent mapping'

Host is behind a NAT which uses a different address for each peer.

Result

Bases: NamedTuple

Result of a NAT mapping behavior check.

Attributes:

  • mapping (NatMapping) –

    Mapping behavior of the NAT this host is behind.

  • external_ip (str) –

    External IP of this host.

  • external_port (int) –

    External port of this host. This is only stable across peers when mapping is not AddressDependent.

  • hole_punching_likely (bool) –

    Whether NAT traversal is expected to work.

check_nat async

check_nat(
    source_ip: str = "0.0.0.0",
    source_port: int = 0,
    timeout: float = 2.0,
) -> Result

Check the NAT mapping behavior of this host.

Sends STUN binding requests from a single socket to several servers on different IP addresses. If every server reflects the same external address then the NAT reuses one mapping for all destinations and hole-punching can work. If the addresses differ then the mapping is address-dependent and a relay is required.

Parameters:

  • source_ip (str, default: '0.0.0.0' ) –

    Address to bind to.

  • source_port (int, default: 0 ) –

    Port to bind to. The default binds an ephemeral port.

  • timeout (float, default: 2.0 ) –

    Maximum number of seconds to wait for responses.

Returns:

  • Result

    Result describing the mapping behavior and external address.

Raises:

  • RuntimeError

    if fewer than two STUN servers respond, in which case the mapping behavior cannot be determined.

Source code in proxystore/p2p/nat.py
async def check_nat(
    source_ip: str = '0.0.0.0',
    source_port: int = 0,
    timeout: float = 2.0,
) -> Result:
    """Check the NAT mapping behavior of this host.

    Sends STUN binding requests from a single socket to several servers on
    different IP addresses. If every server reflects the same external
    address then the NAT reuses one mapping for all destinations and
    hole-punching can work. If the addresses differ then the mapping is
    address-dependent and a relay is required.

    Args:
        source_ip: Address to bind to.
        source_port: Port to bind to. The default binds an ephemeral port.
        timeout: Maximum number of seconds to wait for responses.

    Returns:
        Result describing the mapping behavior and external address.

    Raises:
        RuntimeError: if fewer than two STUN servers respond, in which case
            the mapping behavior cannot be determined.
    """
    servers = await _resolve_servers(_STUN_SERVERS)
    if len(servers) < 2:
        raise RuntimeError(
            f'Only {len(servers)} STUN servers could be resolved but at '
            'least two are needed to determine NAT mapping behavior.',
        )

    loop = asyncio.get_running_loop()
    transport, protocol = await loop.create_datagram_endpoint(
        lambda: _StunProtocol(len(servers)),
        local_addr=(source_ip, source_port),
        family=socket.AF_INET,
    )

    requests = {secrets.token_bytes(_TRANSACTION_ID_BYTES): s for s in servers}
    deadline = loop.time() + timeout

    try:
        for delay in _RETRANSMIT_DELAYS:
            pending = [
                (transaction_id, server)
                for transaction_id, server in requests.items()
                if transaction_id not in protocol.responses
            ]
            if not pending:
                break

            for transaction_id, server in pending:
                transport.sendto(_encode_request(transaction_id), server)

            remaining = min(delay, deadline - loop.time())
            if remaining <= 0:
                break

            with contextlib.suppress(asyncio.TimeoutError):
                await asyncio.wait_for(protocol.complete.wait(), remaining)
    finally:
        transport.close()

    addresses = list(protocol.responses.values())
    if len(addresses) < 2:
        raise RuntimeError(
            f'Only {len(addresses)} of {len(servers)} STUN servers responded '
            'but at least two are needed to determine NAT mapping behavior. '
            'Enable debug level logging for more details.',
        )

    external_ip, external_port = addresses[0]

    if any(address != addresses[0] for address in addresses):
        mapping = NatMapping.AddressDependent
    elif external_ip == _local_address(servers[0]):
        mapping = NatMapping.NoNat
    else:
        mapping = NatMapping.EndpointIndependent

    return Result(
        mapping=mapping,
        external_ip=external_ip,
        external_port=external_port,
        hole_punching_likely=mapping is not NatMapping.AddressDependent,
    )

check_nat_and_log async

check_nat_and_log(
    source_ip: str = "0.0.0.0",
    source_port: int = 0,
    timeout: float = 2.0,
) -> None

Check the NAT mapping behavior of this host and log the results.

Wrapper around check_nat() that logs the results rather than return them.

Parameters:

  • source_ip (str, default: '0.0.0.0' ) –

    Address to bind to.

  • source_port (int, default: 0 ) –

    Port to bind to. The default binds an ephemeral port.

  • timeout (float, default: 2.0 ) –

    Maximum number of seconds to wait for responses.

Source code in proxystore/p2p/nat.py
async def check_nat_and_log(
    source_ip: str = '0.0.0.0',
    source_port: int = 0,
    timeout: float = 2.0,
) -> None:
    """Check the NAT mapping behavior of this host and log the results.

    Wrapper around [`check_nat()`][proxystore.p2p.nat.check_nat]
    that logs the results rather than return them.

    Args:
        source_ip: Address to bind to.
        source_port: Port to bind to. The default binds an ephemeral port.
        timeout: Maximum number of seconds to wait for responses.
    """
    logger.info('Checking NAT behavior. This may take a moment...')
    try:
        result = await check_nat(
            source_ip=source_ip,
            source_port=source_port,
            timeout=timeout,
        )
    except Exception as e:
        logger.error(f'Failed to determine NAT behavior: {e}')
        return

    logger.info(f'NAT Behavior:   {result.mapping.value}')
    logger.info(f'External IP:    {result.external_ip}')
    logger.info(f'External Port:  {result.external_port}')

    if result.hole_punching_likely:
        logger.info(
            'NAT traversal for peer-to-peer methods (e.g., hole-punching) '
            'is likely to work.',
        )
    else:
        logger.warning(
            'This NAT assigns a different external address to each peer so '
            'NAT traversal (e.g., hole-punching) will not work reliably. '
            'Peer-to-peer methods may require a relay.',
        )