dig_nat/method/upnp.rs
1//! UPnP/IGD method — ask the local Internet Gateway Device (via UPnP SSDP discovery + SOAP) to add
2//! a port mapping so inbound peer dials reach this node.
3//!
4//! Unlike NAT-PMP/PCP, UPnP/IGD is a large protocol (SSDP multicast discovery + SOAP over HTTP), so
5//! we do NOT hand-roll it — we use the maintained [`igd-next`](https://docs.rs/igd-next) crate for
6//! the live gateway call. It sits behind the same [`TraversalMethod`] trait as the other methods, so
7//! the ordering/fallback strategy is exercised with mock methods in tests and the live IGD call is
8//! covered only by an opt-in integration test (it needs a real UPnP gateway on the LAN).
9//!
10//! Testability: the gateway interaction is abstracted behind the [`IgdGateway`] trait so the
11//! method's own logic (map → yield dial address, or fail → fall through) is unit-tested with a fake
12//! gateway. [`RealIgd`] is the production implementation delegating to `igd-next`.
13
14use std::net::SocketAddr;
15use std::sync::Arc;
16use std::time::Duration;
17
18use async_trait::async_trait;
19
20use crate::error::MethodError;
21use crate::method::{MethodOutcome, TraversalKind, TraversalMethod};
22use crate::peer::PeerTarget;
23
24/// Abstraction over "add a port mapping on the local IGD". Real impl talks to `igd-next`; tests use
25/// a fake so the method logic is verified with no network + no gateway.
26#[async_trait]
27pub trait IgdGateway: Send + Sync {
28 /// Add a UDP port mapping `external_port → (this host):internal_port` for `lifetime_secs`.
29 /// Returns the external port actually assigned. Err = the gateway refused / is absent.
30 async fn add_port_mapping(&self, internal_port: u16, lifetime_secs: u32)
31 -> Result<u16, String>;
32}
33
34/// Production [`IgdGateway`] backed by `igd-next` (async tokio). Discovers the gateway via SSDP and
35/// adds a UDP mapping. Kept thin so the one real network call is isolated.
36#[derive(Debug, Clone)]
37pub struct RealIgd {
38 /// SSDP discovery timeout.
39 pub discovery_timeout: Duration,
40}
41
42impl Default for RealIgd {
43 fn default() -> Self {
44 RealIgd {
45 discovery_timeout: Duration::from_secs(2),
46 }
47 }
48}
49
50#[async_trait]
51impl IgdGateway for RealIgd {
52 async fn add_port_mapping(
53 &self,
54 internal_port: u16,
55 lifetime_secs: u32,
56 ) -> Result<u16, String> {
57 use igd_next::aio::tokio as igd_tokio;
58 use igd_next::{PortMappingProtocol, SearchOptions};
59
60 let opts = SearchOptions {
61 timeout: Some(self.discovery_timeout),
62 ..Default::default()
63 };
64 let gateway = igd_tokio::search_gateway(opts)
65 .await
66 .map_err(|e| format!("igd discovery: {e}"))?;
67 let local_ip = local_ipv4().ok_or_else(|| "no local IPv4 to map".to_string())?;
68 let local = SocketAddr::new(local_ip, internal_port);
69 gateway
70 .add_port(
71 PortMappingProtocol::UDP,
72 internal_port,
73 local,
74 lifetime_secs,
75 "dig-nat",
76 )
77 .await
78 .map_err(|e| format!("igd add_port: {e}"))?;
79 Ok(internal_port)
80 }
81}
82
83/// Forward through a shared trait object so [`crate::connect`] can compose a UPnP method from an
84/// `Arc<dyn IgdGateway>` held in the runtime carrier (a generic [`UpnpMethod<G>`] over the object).
85#[async_trait]
86impl IgdGateway for Arc<dyn IgdGateway> {
87 async fn add_port_mapping(
88 &self,
89 internal_port: u16,
90 lifetime_secs: u32,
91 ) -> Result<u16, String> {
92 (**self)
93 .add_port_mapping(internal_port, lifetime_secs)
94 .await
95 }
96}
97
98/// The UPnP/IGD traversal method. Adds a mapping via [`IgdGateway`], then yields a dial address for
99/// the peer. Generic over the gateway so tests inject a fake.
100pub struct UpnpMethod<G: IgdGateway> {
101 gateway: G,
102 /// The local port to map.
103 pub local_port: u16,
104 /// Requested mapping lifetime (seconds).
105 pub lifetime_secs: u32,
106}
107
108impl<G: IgdGateway> UpnpMethod<G> {
109 /// Build a UPnP method over `gateway` for `local_port`.
110 pub fn new(gateway: G, local_port: u16) -> Self {
111 UpnpMethod {
112 gateway,
113 local_port,
114 lifetime_secs: 7200,
115 }
116 }
117}
118
119/// The production UPnP method (real IGD discovery).
120pub type RealUpnpMethod = UpnpMethod<RealIgd>;
121
122impl RealUpnpMethod {
123 /// Convenience constructor for the production method.
124 pub fn real(local_port: u16) -> Self {
125 UpnpMethod::new(RealIgd::default(), local_port)
126 }
127}
128
129#[async_trait]
130impl<G: IgdGateway> TraversalMethod for UpnpMethod<G> {
131 fn kind(&self) -> TraversalKind {
132 TraversalKind::Upnp
133 }
134
135 async fn attempt(&self, peer: &PeerTarget) -> Result<MethodOutcome, MethodError> {
136 // Carry the peer's whole candidate list so the post-mapping dial (dig-ip) keeps the
137 // IPv6-first / IPv4-fallback order across families.
138 let dial_addrs = peer.direct_addrs();
139 if dial_addrs.is_empty() {
140 return Err(MethodError::failed(
141 TraversalKind::Upnp,
142 "peer has no address to dial after mapping",
143 ));
144 }
145 self.gateway
146 .add_port_mapping(self.local_port, self.lifetime_secs)
147 .await
148 .map_err(|e| MethodError::failed(TraversalKind::Upnp, e))?;
149 Ok(MethodOutcome::candidates(
150 TraversalKind::Upnp,
151 dial_addrs.to_vec(),
152 ))
153 }
154}
155
156/// Best-effort local IPv4 for the mapping target: open a UDP socket "to" a public address (no
157/// packet is sent) and read the OS-selected source address. Returns `None` if unavailable.
158///
159/// UPnP/IGD is IPv4-inherent — it maps an inbound IPv4 pinhole on the gateway — so this IPv4 probe is
160/// the correct local-IP source for the *mapping*. A routable IPv6 candidate (which needs no mapping)
161/// is discovered separately via [`local_ipv6`] and advertised alongside, ordered first.
162fn local_ipv4() -> Option<std::net::IpAddr> {
163 let sock = std::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0)).ok()?;
164 sock.connect((std::net::Ipv4Addr::new(1, 1, 1, 1), 80))
165 .ok()?;
166 sock.local_addr().ok().map(|a| a.ip())
167}
168
169/// Best-effort GLOBAL (routable) local IPv6 address of this host, if any.
170///
171/// A global-unicast IPv6 address is directly dialable by peers with NO NAT mapping (unlike the
172/// IPv4 path, which needs the UPnP pinhole), so it is a first-class candidate the node should
173/// advertise **first** (IPv6-first rule). We ask the OS for the source address it would use to reach
174/// a public IPv6 (a UDP `connect` sends no packet), then keep it only if it is a routable
175/// global-unicast address (never link-local/ULA/loopback — those are not peer-reachable across the
176/// internet). Returns `None` when the host has no global IPv6.
177pub fn local_ipv6() -> Option<std::net::IpAddr> {
178 let sock = std::net::UdpSocket::bind((std::net::Ipv6Addr::UNSPECIFIED, 0)).ok()?;
179 // Cloudflare's public IPv6 resolver — no packet is sent by `connect`, it only selects a route.
180 sock.connect((
181 std::net::Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111),
182 80,
183 ))
184 .ok()?;
185 let ip = sock.local_addr().ok()?.ip();
186 select_global_ipv6(&[ip])
187}
188
189/// Select the best IPv6 address to ADVERTISE from a set of the host's candidate addresses: a
190/// global-unicast address is preferred; link-local (`fe80::/10`), ULA (`fc00::/7`), loopback, and
191/// unspecified addresses are rejected because they are not reachable by peers across the internet.
192/// IPv4 candidates are ignored. Returns the first global-unicast IPv6 candidate, or `None`.
193pub fn select_global_ipv6(candidates: &[std::net::IpAddr]) -> Option<std::net::IpAddr> {
194 candidates
195 .iter()
196 .copied()
197 .find(|ip| matches!(ip, std::net::IpAddr::V6(v6) if is_global_unicast_v6(v6)))
198}
199
200/// Whether an IPv6 address is a routable global-unicast address (excludes loopback, unspecified,
201/// link-local `fe80::/10`, and unique-local/ULA `fc00::/7`). Multicast is not unicast, so excluded.
202fn is_global_unicast_v6(v6: &std::net::Ipv6Addr) -> bool {
203 if v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() {
204 return false;
205 }
206 let seg0 = v6.segments()[0];
207 let is_link_local = (seg0 & 0xffc0) == 0xfe80; // fe80::/10
208 let is_unique_local = (seg0 & 0xfe00) == 0xfc00; // fc00::/7 (ULA)
209 !is_link_local && !is_unique_local
210}