fix(net): harden port mapper from adversarial review

- Shutdown no longer freezes: Drop signalled stop but then blocked on a
  thread join, and nothing observed stop during the blocking UPnP/NAT-PMP
  discovery (up to ~9s). Drop now waits on a done-channel with a 1s cap
  and detaches otherwise, so quitting is always prompt; the finite lease
  cleans up if we detach mid-discovery. Regression-tested against an
  unresponsive gateway.
- Linux default_gateway() no longer returns 0.0.0.0 for a gateway-less
  (directly-connected) default route — it keeps scanning, matching the
  macOS branch, instead of firing pointless NAT-PMP at the unspecified
  address.
- UPnP now retries AddPortMapping with a permanent (lease 0) mapping when
  the router rejects a timed lease (IGDs that only support permanent
  leases), and renews/removes with the accepted lease.
This commit is contained in:
michel
2026-07-19 21:26:51 +02:00
parent 9f74300c7a
commit 84c571767a
2 changed files with 76 additions and 25 deletions
+70 -24
View File
@@ -13,7 +13,8 @@ mod nat_pmp;
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::{self, JoinHandle}; use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use igd_next::{PortMappingProtocol, SearchOptions, search_gateway}; use igd_next::{PortMappingProtocol, SearchOptions, search_gateway};
@@ -29,7 +30,7 @@ const NATPMP_ATTEMPTS: u32 = 4;
/// removes the mapping (best-effort). /// removes the mapping (best-effort).
pub struct PortMapper { pub struct PortMapper {
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>, done: Receiver<()>,
} }
impl PortMapper { impl PortMapper {
@@ -39,20 +40,24 @@ impl PortMapper {
pub fn spawn(port: u16) -> Self { pub fn spawn(port: u16) -> Self {
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let thread_stop = stop.clone(); let thread_stop = stop.clone();
let handle = thread::Builder::new() let (done_tx, done) = mpsc::channel();
.name("port-mapper".into()) let _ = thread::Builder::new().name("port-mapper".into()).spawn(
.spawn(move || run(port, &thread_stop)) move || {
.ok(); run(port, &thread_stop);
Self { stop, handle } let _ = done_tx.send(());
},
);
Self { stop, done }
} }
} }
impl Drop for PortMapper { impl Drop for PortMapper {
fn drop(&mut self) { fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst); self.stop.store(true, Ordering::SeqCst);
if let Some(handle) = self.handle.take() { // Wait briefly for the thread to remove its mapping, but NEVER freeze
let _ = handle.join(); // shutdown: if it's still in a blocking discovery/mapping call (which
} // doesn't observe `stop`), detach it and let the finite lease expire.
let _ = self.done.recv_timeout(Duration::from_secs(1));
} }
} }
@@ -63,6 +68,8 @@ enum Backend {
gateway: Box<igd_next::Gateway>, gateway: Box<igd_next::Gateway>,
local_addr: SocketAddr, local_addr: SocketAddr,
external_port: u16, external_port: u16,
/// The lease that the router accepted (0 == permanent).
lease: u32,
}, },
NatPmp { NatPmp {
gateway: SocketAddr, gateway: SocketAddr,
@@ -129,20 +136,33 @@ fn map_upnp(local_ip: IpAddr, port: u16) -> Option<(Backend, u32)> {
// EXTERNAL port must equal it. If the exact port can't be mapped (e.g. // EXTERNAL port must equal it. If the exact port can't be mapped (e.g.
// another LAN device already claimed it), a different external port would // another LAN device already claimed it), a different external port would
// be unreachable for our advertised address, so we give up rather than map // be unreachable for our advertised address, so we give up rather than map
// a useless port. // a useless port. Some IGDs only accept permanent leases, so if the timed
if let Err(e) = gateway.add_port( // lease is refused, retry with lease 0 (permanent) before giving up.
let lease = match gateway.add_port(
PortMappingProtocol::TCP, PortMappingProtocol::TCP,
port, port,
local_addr, local_addr,
LEASE_SECS, LEASE_SECS,
MAPPING_DESCRIPTION, MAPPING_DESCRIPTION,
) { ) {
warn(&format!( Ok(()) => LEASE_SECS,
"UPnP found a router but couldn't map port {port} ({e}); \ Err(_) => match gateway.add_port(
try a different --listener-port or forward it manually" PortMappingProtocol::TCP,
)); port,
return None; local_addr,
} 0,
MAPPING_DESCRIPTION,
) {
Ok(()) => 0,
Err(e) => {
warn(&format!(
"UPnP found a router but couldn't map port {port} ({e}); \
try a different --listener-port or forward it manually"
));
return None;
}
},
};
let external = gateway let external = gateway
.get_external_ip() .get_external_ip()
.map_or_else(|_| "?".to_string(), |ip| ip.to_string()); .map_or_else(|_| "?".to_string(), |ip| ip.to_string());
@@ -154,8 +174,11 @@ fn map_upnp(local_ip: IpAddr, port: u16) -> Option<(Backend, u32)> {
gateway: Box::new(gateway), gateway: Box::new(gateway),
local_addr, local_addr,
external_port: port, external_port: port,
lease,
}, },
LEASE_SECS, // A permanent (0) lease needs no renewal timer; use the requested lease
// for the renewal cadence otherwise.
if lease == 0 { LEASE_SECS } else { lease },
)) ))
} }
@@ -194,12 +217,13 @@ fn renew(backend: &Backend, port: u16) -> bool {
gateway, gateway,
local_addr, local_addr,
external_port, external_port,
lease,
} => gateway } => gateway
.add_port( .add_port(
PortMappingProtocol::TCP, PortMappingProtocol::TCP,
*external_port, *external_port,
*local_addr, *local_addr,
LEASE_SECS, *lease,
MAPPING_DESCRIPTION, MAPPING_DESCRIPTION,
) )
.is_ok(), .is_ok(),
@@ -280,7 +304,8 @@ impl PortMapper {
fn spawn_natpmp_test(port: u16, gateway: SocketAddr) -> Self { fn spawn_natpmp_test(port: u16, gateway: SocketAddr) -> Self {
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let thread_stop = stop.clone(); let thread_stop = stop.clone();
let handle = thread::Builder::new() let (done_tx, done) = mpsc::channel();
let _ = thread::Builder::new()
.name("port-mapper-test".into()) .name("port-mapper-test".into())
.spawn(move || { .spawn(move || {
if let Ok(response) = nat_pmp::map_tcp( if let Ok(response) = nat_pmp::map_tcp(
@@ -296,9 +321,9 @@ impl PortMapper {
}; };
manage(&backend, response.lifetime, port, &thread_stop); manage(&backend, response.lifetime, port, &thread_stop);
} }
}) let _ = done_tx.send(());
.ok(); });
Self { stop, handle } Self { stop, done }
} }
} }
@@ -379,4 +404,25 @@ mod tests {
"cleanup should request lifetime 0" "cleanup should request lifetime 0"
); );
} }
#[test]
fn drop_does_not_hang_when_gateway_is_unresponsive() {
// A bound-but-silent gateway: the mapper thread blocks in the NAT-PMP
// retransmit budget (~3.75s). Dropping the mapper must NOT wait that
// long — it detaches after a bounded grace period.
let silent = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let addr = silent.local_addr().unwrap();
let mapper = PortMapper::spawn_natpmp_test(2234, addr);
// Ensure the thread is mid-transaction before we drop.
thread::sleep(StdDuration::from_millis(100));
let start = Instant::now();
drop(mapper);
let elapsed = start.elapsed();
assert!(
elapsed < StdDuration::from_millis(1500),
"drop blocked for {elapsed:?}; it should detach promptly"
);
drop(silent);
}
} }
+6 -1
View File
@@ -196,7 +196,12 @@ pub fn default_gateway() -> Option<Ipv4Addr> {
let gateway = fields.next()?; let gateway = fields.next()?;
if destination == "00000000" { if destination == "00000000" {
let raw = u32::from_str_radix(gateway, 16).ok()?; let raw = u32::from_str_radix(gateway, 16).ok()?;
return Some(Ipv4Addr::from(raw.to_le_bytes())); // A 0.0.0.0 gateway is a directly-connected default route (no
// next hop): not a usable NAT-PMP gateway. Keep scanning for a
// real one rather than returning the unspecified address.
if raw != 0 {
return Some(Ipv4Addr::from(raw.to_le_bytes()));
}
} }
} }
None None