Skip to content

Commit 4d8a984

Browse files
authored
Merge branch 'main' into pnet
2 parents d302f53 + 9f26ceb commit 4d8a984

File tree

16 files changed

+573
-13
lines changed

16 files changed

+573
-13
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,4 @@ _(non-normative, useful for team notes, not a reference)_
138138

139139
**Communication over one connection with multiple protocols**: X and Y can communicate over the same connection using different protocols and the multiplexer will appropriately route messages for a given protocol to a particular handler function for that protocol, which allows for each host to handle different protocols with separate functions. Furthermore, we can use multiple streams for a given protocol that allow for the same protocol and same underlying connection to be used for communication about separate topics between nodes X and Y.
140140

141-
**Why use multiple streams?**: The purpose of using the same connection for multiple streams to communicate over is to avoid the overhead of having multiple connections between X and Y. In order for X and Y to differentiate between messages on different streams and different protocols, a multiplexer is used to encode the messages when a message will be sent and decode a message when a message is received. The multiplexer encodes the message by adding a header to the beginning of any message to be sent that contains the stream id (along with some other info). Then, the message is sent across the raw connection and the receiving host will use its multiplexer to decode the message, i.e. determine which stream id the message should be routed to.
141+
**Why use multiple streams?**: The purpose of using the same connection for multiple streams to communicate over is to avoid the overhead of having multiple connections between X and Y. In order for X and Y to differentiate between messages on different streams and different protocols, a multiplexer is used to encode the messages when a message will be sent and decode a message when a message is received. The multiplexer encodes the message by adding a header to the beginning of any message to be sent that contains the stream id (along with some other info). Then, the message is sent across the raw connection and the receiving host will use its multiplexer to decode the message, i.e. determine which stream id the message should be routed to.

docs/libp2p.discovery.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Subpackages
1212
libp2p.discovery.mdns
1313
libp2p.discovery.random_walk
1414
libp2p.discovery.rendezvous
15+
libp2p.discovery.upnp
1516

1617
Submodules
1718
----------

docs/libp2p.discovery.upnp.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
libp2p.discovery.upnp package
2+
=============================
3+
4+
Submodules
5+
----------
6+
7+
libp2p.discovery.upnp.upnp module
8+
---------------------------------
9+
10+
.. automodule:: libp2p.discovery.upnp.upnp
11+
:members:
12+
:show-inheritance:
13+
:undoc-members:
14+
15+
Module contents
16+
---------------
17+
18+
.. automodule:: libp2p.discovery.upnp
19+
:members:
20+
:show-inheritance:
21+
:undoc-members:

examples/upnp/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# UPnP Example
2+
3+
This example demonstrates how to use the integrated UPnP behaviour to automatically create a port mapping on your local network's gateway (e.g., your home router).
4+
5+
This allows peers from the public internet to directly dial your node, which is a crucial step for NAT traversal.
6+
7+
## Usage
8+
9+
First, ensure you have installed the necessary dependencies from the root of the repository:
10+
11+
```sh
12+
pip install -e .
13+
```
14+
15+
Then, run the script in a terminal:
16+
17+
```sh
18+
python examples/upnp/upnp_demo.py
19+
```
20+
21+
The script will start a libp2p host and immediately try to discover a UPnP-enabled gateway on the network.
22+
23+
- If it **succeeds**, it will print the new external address that has been mapped.
24+
- If it **fails** (e.g., your router doesn't have UPnP enabled or you're behind a double NAT), it will print a descriptive error message.

examples/upnp/upnp_demo.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import argparse
2+
import logging
3+
4+
import multiaddr
5+
import trio
6+
7+
from libp2p import new_host
8+
9+
logging.basicConfig(level=logging.INFO)
10+
logger = logging.getLogger("upnp-demo")
11+
12+
13+
async def run(port: int) -> None:
14+
listen_maddr = multiaddr.Multiaddr(f"/ip4/0.0.0.0/tcp/{port}")
15+
host = new_host(enable_upnp=True)
16+
17+
async with host.run(listen_addrs=[listen_maddr]):
18+
try:
19+
logger.info(f"Host started with ID: {host.get_id().pretty()}")
20+
logger.info(f"Listening on: {host.get_addrs()}")
21+
logger.info("Host is running. Press Ctrl+C to shut down.")
22+
logger.info("If UPnP discovery was successful, ports are now mapped.")
23+
await trio.sleep_forever()
24+
except KeyboardInterrupt:
25+
logger.info("Shutting down...")
26+
finally:
27+
# UPnP teardown is automatic via host.run()
28+
logger.info("Shutdown complete.")
29+
30+
31+
def main() -> None:
32+
parser = argparse.ArgumentParser(description="UPnP example for py-libp2p")
33+
parser.add_argument(
34+
"-p", "--port", type=int, default=0, help="Local TCP port (0 for random)"
35+
)
36+
args = parser.parse_args()
37+
38+
try:
39+
trio.run(run, args.port)
40+
except KeyboardInterrupt:
41+
pass
42+
43+
44+
if __name__ == "__main__":
45+
main()

libp2p/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -316,10 +316,11 @@ def new_host(
316316
muxer_preference: Literal["YAMUX", "MPLEX"] | None = None,
317317
listen_addrs: Sequence[multiaddr.Multiaddr] | None = None,
318318
enable_mDNS: bool = False,
319+
enable_upnp: bool = False,
319320
bootstrap: list[str] | None = None,
320321
negotiate_timeout: int = DEFAULT_NEGOTIATE_TIMEOUT,
321322
enable_quic: bool = False,
322-
quic_transport_opt: QUICTransportConfig | None = None,
323+
quic_transport_opt: QUICTransportConfig | None = None,
323324
tls_client_config: ssl.SSLContext | None = None,
324325
tls_server_config: ssl.SSLContext | None = None,
325326
psk: str | None = None
@@ -362,13 +363,13 @@ def new_host(
362363
)
363364

364365
if disc_opt is not None:
365-
return RoutedHost(swarm, disc_opt, enable_mDNS, bootstrap)
366+
return RoutedHost(swarm, disc_opt, enable_mDNS, enable_upnp, bootstrap)
366367
return BasicHost(
367368
network=swarm,
368369
enable_mDNS=enable_mDNS,
369370
bootstrap=bootstrap,
371+
enable_upnp=enable_upnp,
370372
negotiate_timeout=negotiate_timeout
371373
)
372374

373-
374375
__version__ = __version("libp2p")

libp2p/discovery/upnp/__init__.py

Whitespace-only changes.

libp2p/discovery/upnp/upnp.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import ipaddress
2+
import logging
3+
4+
import miniupnpc
5+
import trio
6+
7+
logger = logging.getLogger("libp2p.discovery.upnp")
8+
9+
10+
class UpnpManager:
11+
"""
12+
A simple, self-contained manager for UPnP port mapping that can be used
13+
alongside a libp2p Host.
14+
"""
15+
16+
def __init__(self) -> None:
17+
self._gateway = miniupnpc.UPnP()
18+
self._lan_addr: str | None = None
19+
self._external_ip: str | None = None
20+
21+
def _validate_igd(self) -> bool:
22+
"""
23+
Validate that the selected device is actually an Internet Gateway Device.
24+
25+
:return: True if the device is a valid IGD, False otherwise.
26+
"""
27+
try:
28+
# Check if we can get basic IGD information
29+
if not self._gateway.lanaddr:
30+
logger.debug("IGD validation failed: No LAN address available")
31+
return False
32+
33+
# Try to get the external IP - this is a good test of IGD validity
34+
external_ip = self._gateway.externalipaddress()
35+
if not external_ip:
36+
logger.debug("IGD validation failed: No external IP address available")
37+
return False
38+
39+
# Additional validation: check if we can get the connection type
40+
# This is a more advanced IGD feature that non-IGD devices typically
41+
# don't support
42+
try:
43+
connection_type = self._gateway.connectiontype()
44+
if connection_type:
45+
logger.debug(f"IGD connection type: {connection_type}")
46+
except Exception:
47+
# Connection type is optional, so we don't fail if it's not available
48+
logger.debug("IGD connection type not available (this is optional)")
49+
50+
return True
51+
except Exception as e:
52+
logger.debug(f"IGD validation failed: {e}")
53+
return False
54+
55+
async def discover(self) -> bool:
56+
"""
57+
Discover the UPnP IGD on the network.
58+
59+
:return: True if a gateway is found, False otherwise.
60+
"""
61+
logger.debug("Discovering UPnP gateway...")
62+
try:
63+
try:
64+
num_devices = await trio.to_thread.run_sync(self._gateway.discover)
65+
except Exception as e:
66+
# The miniupnpc library has a documented quirk where `discover()` can
67+
# raise an exception with the message "Success" on some platforms
68+
# (particularly Windows) due to inconsistent error handling in the C
69+
# library. This is a known issue in miniupnpc where successful
70+
# discovery sometimes raises an exception instead of returning a count.
71+
# See: https://github.com/miniupnp/miniupnp/issues/
72+
if str(e) == "Success": # type: ignore
73+
num_devices = 1
74+
else:
75+
logger.exception("UPnP discovery exception")
76+
return False
77+
78+
if num_devices > 0:
79+
logger.debug(f"Found {num_devices} UPnP device(s), selecting IGD...")
80+
try:
81+
await trio.to_thread.run_sync(self._gateway.selectigd)
82+
except Exception as e:
83+
logger.error(f"Failed to select IGD: {e}")
84+
logger.error(
85+
"UPnP devices were found, but none are valid Internet "
86+
"Gateway Devices. Check your router's UPnP/IGD settings."
87+
)
88+
return False
89+
90+
# Validate that the selected device is actually an IGD
91+
if not await trio.to_thread.run_sync(self._validate_igd):
92+
logger.error(
93+
"Selected UPnP device is not a valid Internet Gateway Device. "
94+
"The device may be a smart home device or other UPnP device "
95+
"that doesn't support port mapping."
96+
)
97+
return False
98+
99+
self._lan_addr = self._gateway.lanaddr
100+
self._external_ip = await trio.to_thread.run_sync(
101+
self._gateway.externalipaddress
102+
)
103+
logger.debug(f"UPnP gateway found: {self._external_ip}")
104+
105+
if self._external_ip is None:
106+
logger.error("Gateway did not return an external IP address")
107+
return False
108+
109+
ip_obj = ipaddress.ip_address(self._external_ip)
110+
if ip_obj.is_private:
111+
logger.warning(
112+
"UPnP gateway has a private IP; you may be behind a double NAT."
113+
)
114+
return False
115+
return True
116+
else:
117+
logger.debug("No UPnP devices found")
118+
return False
119+
except Exception:
120+
logger.exception("UPnP discovery failed")
121+
return False
122+
123+
async def add_port_mapping(self, port: int, protocol: str = "TCP") -> bool:
124+
"""
125+
Request a new port mapping from the gateway.
126+
127+
:param port: the internal port to map
128+
:param protocol: the protocol to map (TCP or UDP)
129+
:return: True on success, False otherwise
130+
"""
131+
try:
132+
port = int(port)
133+
if not 0 < port < 65536:
134+
logger.error(f"Invalid port number for mapping: {port}")
135+
return False
136+
except (ValueError, TypeError):
137+
logger.error(f"Invalid port value: {port}")
138+
return False
139+
if port < 1024:
140+
logger.warning(
141+
f"Mapping a well-known (privileged) port ({port}) may fail or "
142+
"require root."
143+
)
144+
145+
if not self._lan_addr:
146+
logger.error(
147+
"Cannot add port mapping: discovery has not been run successfully."
148+
)
149+
return False
150+
151+
logger.debug(f"Requesting UPnP mapping for {protocol} port {port}...")
152+
try:
153+
await trio.to_thread.run_sync(
154+
lambda: self._gateway.addportmapping(
155+
port, protocol, self._lan_addr, port, "py-libp2p", ""
156+
)
157+
)
158+
logger.info(
159+
f"Successfully mapped external port {self._external_ip}:{port} "
160+
f"to internal port {self._lan_addr}:{port}"
161+
)
162+
return True
163+
except Exception:
164+
logger.exception(f"Failed to map port {port}")
165+
return False
166+
167+
async def remove_port_mapping(self, port: int, protocol: str = "TCP") -> bool:
168+
"""
169+
Remove an existing port mapping.
170+
171+
:param port: the external port to unmap
172+
:param protocol: the protocol (TCP or UDP)
173+
:return: True on success, False otherwise
174+
"""
175+
try:
176+
port = int(port)
177+
if not 0 < port < 65536:
178+
logger.error(f"Invalid port number for removal: {port}")
179+
return False
180+
except (ValueError, TypeError):
181+
logger.error(f"Invalid port value: {port}")
182+
return False
183+
184+
logger.debug(f"Removing UPnP mapping for {protocol} port {port}...")
185+
try:
186+
await trio.to_thread.run_sync(
187+
lambda: self._gateway.deleteportmapping(port, protocol)
188+
)
189+
logger.info(f"Successfully removed mapping for port {port}")
190+
return True
191+
except Exception:
192+
logger.exception(f"Failed to remove mapping for port {port}")
193+
return False
194+
195+
def get_external_ip(self) -> str | None:
196+
return self._external_ip

0 commit comments

Comments
 (0)