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:
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
Ok(()) => LEASE_SECS,
|
||||||
|
Err(_) => match gateway.add_port(
|
||||||
|
PortMappingProtocol::TCP,
|
||||||
|
port,
|
||||||
|
local_addr,
|
||||||
|
0,
|
||||||
|
MAPPING_DESCRIPTION,
|
||||||
|
) {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(e) => {
|
||||||
warn(&format!(
|
warn(&format!(
|
||||||
"UPnP found a router but couldn't map port {port} ({e}); \
|
"UPnP found a router but couldn't map port {port} ({e}); \
|
||||||
try a different --listener-port or forward it manually"
|
try a different --listener-port or forward it manually"
|
||||||
));
|
));
|
||||||
return None;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,9 +196,14 @@ 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()?;
|
||||||
|
// 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()));
|
return Some(Ipv4Addr::from(raw.to_le_bytes()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
|||||||
Reference in New Issue
Block a user