Skip to main content

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