diff --git a/Cargo.lock b/Cargo.lock index 5de40c3e5..6ce39384e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -209,6 +209,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -287,7 +299,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -433,7 +445,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", ] [[package]] @@ -443,7 +466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -622,6 +645,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "criterion" version = "0.5.1" @@ -734,7 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1288,6 +1320,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1455,6 +1506,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1643,6 +1695,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac9a3c8278f43b4cd8463380f4a25653ac843e5b177e1d3eaf849cc9ba10d4d" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.0", + "tokio", + "url", + "xmltree", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1822,7 +1894,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2244,7 +2316,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2256,7 +2328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2514,6 +2586,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" dependencies = [ + "chacha20 0.10.0", "getrandom 0.4.2", "rand_core 0.10.0", ] @@ -2953,6 +3026,7 @@ dependencies = [ "futures-util", "hex", "hex-literal", + "igd-next", "indexmap", "keyring", "lazy_static", @@ -3180,7 +3254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4602,6 +4676,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8aa498d22c9bbaf482329839bc5620c46be275a19a812e9a22a2b07529a642a" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "yasna" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 066235b06..75326e6a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,11 +39,18 @@ exclude = [ [features] # Default features include essential functionality with 100% PQC support # v0.15.0: Simplified feature flags - crypto is always enabled -default = ["platform-verifier", "network-discovery"] +default = ["platform-verifier", "network-discovery", "upnp"] # Platform-specific certificate verification platform-verifier = ["dep:rustls-platform-verifier"] +# UPnP IGD port mapping for best-effort NAT traversal assistance. +# When enabled, the endpoint will opportunistically request a UDP port +# mapping from a local Internet Gateway Device. Failure is silent and +# non-fatal — the endpoint behaves identically to a non-UPnP build when +# no gateway is available. +upnp = ["dep:igd-next"] + # Configure `tracing` to log events via `log` if no `tracing` subscriber exists log = ["tracing/log"] @@ -113,6 +120,12 @@ rustls-post-quantum = { version = "0.2", features = ["aws-lc-rs-unstable"] } socket2 = { version = "0.5", optional = true } nix = { version = "0.29", features = ["resource", "net"], optional = true } +# UPnP IGD port mapping (optional) +# Used by the `upnp` feature for best-effort UDP port mapping. The +# implementation never blocks startup and silently degrades when the +# router does not support or has disabled UPnP IGD. +igd-next = { version = "0.17", default-features = false, features = ["aio_tokio"], optional = true } + # BLE transport dependencies (cross-platform, optional) # btleplug supports Linux (BlueZ), macOS (Core Bluetooth), and Windows (WinRT) btleplug = { version = "0.11", optional = true } diff --git a/benches/nat_traversal.rs b/benches/nat_traversal.rs index bdc573e40..f841dad03 100644 --- a/benches/nat_traversal.rs +++ b/benches/nat_traversal.rs @@ -565,6 +565,7 @@ fn bench_pair_generation(c: &mut Criterion) { CandidateSource::Observed { .. } => 1, CandidateSource::Peer => 2, CandidateSource::Predicted => 3, + CandidateSource::PortMapped => 4, }; for remote in &remote_candidates { @@ -585,6 +586,7 @@ fn bench_pair_generation(c: &mut Criterion) { CandidateSource::Observed { .. } => 1, CandidateSource::Peer => 2, CandidateSource::Predicted => 3, + CandidateSource::PortMapped => 4, }; // Calculate priority diff --git a/src/bin/saorsa-transport.rs b/src/bin/saorsa-transport.rs index c5be22936..f29038d5b 100644 --- a/src/bin/saorsa-transport.rs +++ b/src/bin/saorsa-transport.rs @@ -170,6 +170,14 @@ struct Args { /// Chunk size for data generation/verification (bytes) #[arg(long, default_value = "65536")] chunk_size: usize, + + /// Disable best-effort UPnP IGD port mapping. By default the endpoint + /// asks the local router to forward its UDP port — pass this flag to + /// skip the UPnP probe entirely (useful when the router is known to + /// be hostile or when running on infrastructure that does not need + /// it). NAT traversal still works without UPnP via hole punching. + #[arg(long)] + no_upnp: bool, } /// CLI subcommands @@ -371,6 +379,15 @@ async fn main() -> anyhow::Result<()> { } // v0.13.0: No mode-based NAT config - all nodes are symmetric + if args.no_upnp { + let nat = saorsa_transport::unified_config::NatConfig { + upnp: saorsa_transport::upnp::UpnpConfig::disabled(), + ..saorsa_transport::unified_config::NatConfig::default() + }; + builder = builder.nat(nat); + info!("UPnP IGD port mapping disabled (--no-upnp)"); + } + let config = builder.build()?; // Create endpoint diff --git a/src/candidate_discovery.rs b/src/candidate_discovery.rs index 448904fce..17d4e087f 100644 --- a/src/candidate_discovery.rs +++ b/src/candidate_discovery.rs @@ -28,6 +28,14 @@ use crate::{ nat_traversal_api::{BootstrapNode, CandidateAddress}, }; +/// Discovery-side priority assigned to UPnP port-mapped candidates. +/// +/// Slotted strictly above the bound-address promotion (`60_000`) so that +/// a router-confirmed public mapping always outranks any host-side +/// candidate during pairing. The constant lives here so the priority +/// scale stays in one file alongside the other discovery priorities. +const PORT_MAPPED_DISCOVERY_PRIORITY: u32 = 70_000; + /// Session identifier for the candidate discovery manager. /// /// Replaces the legacy `PeerId` key. Each discovery session is either for @@ -66,6 +74,7 @@ fn convert_to_nat_source(discovery_source: DiscoverySourceType) -> CandidateSour DiscoverySourceType::Local => CandidateSource::Local, DiscoverySourceType::ServerReflexive => CandidateSource::Observed { by_node: None }, DiscoverySourceType::Predicted => CandidateSource::Predicted, + DiscoverySourceType::PortMapped => CandidateSource::PortMapped, } } @@ -92,6 +101,14 @@ pub enum DiscoverySourceType { /// These are algorithmically predicted addresses that might work based on /// observed NAT traversal patterns and port prediction algorithms. Predicted, + + /// Public address obtained from a router-side port mapping (UPnP IGD). + /// + /// The gateway has explicitly committed to forwarding the mapped port to + /// our local socket for the lease duration, so these candidates are + /// strictly more reliable than [`Self::ServerReflexive`] addresses + /// observed via peer reports. + PortMapped, } /// IPv6 address type classification for priority calculation. @@ -211,6 +228,17 @@ pub struct CandidateDiscoveryManager { active_sessions: HashMap, /// Cached local interface results (shared across all sessions) cached_local_candidates: Option<(Instant, Vec)>, + /// Optional read-only handle to the UPnP mapping service. When set, + /// the current mapping is surfaced as a high-priority candidate + /// during the local-scanning phase. The handle is purely additive — + /// when absent or in [`crate::upnp::UpnpState::Unavailable`], + /// discovery proceeds exactly as it would in a non-UPnP build. + /// + /// This is a `UpnpStateRx` rather than `Arc` so + /// the discovery manager only borrows the state, leaving the + /// `NatTraversalEndpoint` as the sole owner of the service for + /// graceful shutdown. + upnp: Option, } /// Configuration for candidate discovery behavior @@ -680,9 +708,24 @@ impl CandidateDiscoveryManager { interface_discovery, active_sessions: HashMap::new(), cached_local_candidates: None, + upnp: None, } } + /// Attach a read-only handle to the UPnP mapping service whose current + /// state should be surfaced as a discovery candidate during local + /// scanning. + /// + /// Calling this is optional and best-effort — if the handle never + /// reaches [`crate::upnp::UpnpState::Mapped`], discovery behaves + /// identically to a manager without UPnP attached. + /// + /// Internal plumbing hook for the endpoint constructor; not exposed + /// on the public API surface. + pub(crate) fn set_upnp_state_rx(&mut self, state_rx: crate::upnp::UpnpStateRx) { + self.upnp = Some(state_rx); + } + /// Set the actual bound address of the local endpoint pub fn set_bound_address(&mut self, address: SocketAddr) { self.config.bound_address = Some(address); @@ -690,6 +733,58 @@ impl CandidateDiscoveryManager { self.cached_local_candidates = None; } + /// Snapshot the UPnP mapping (if any) as a [`DiscoveryCandidate`]. + /// + /// Returns `None` when no service is attached, when the service is + /// still probing, or when it has reached the sticky `Unavailable` + /// state. The peek is a single atomic load on the underlying watch + /// channel and is cheap to call from the discovery hot path. + fn upnp_candidate(&self) -> Option { + let state_rx = self.upnp.as_ref()?; + match state_rx.current() { + crate::upnp::UpnpState::Mapped { external, .. } => Some(DiscoveryCandidate { + address: external, + priority: PORT_MAPPED_DISCOVERY_PRIORITY, + source: DiscoverySourceType::PortMapped, + state: CandidateState::New, + }), + crate::upnp::UpnpState::Probing | crate::upnp::UpnpState::Unavailable => None, + } + } + + /// Idempotently push the current UPnP candidate (if any) into `session`, + /// emitting a `LocalCandidateDiscovered` event the first time it appears. + /// + /// Safe to call repeatedly from the same `poll()` invocation — duplicate + /// candidates with the same external address are detected and skipped, + /// matching the dedup discipline used for bound-address promotion. + fn try_publish_upnp_candidate( + upnp_candidate: Option<&DiscoveryCandidate>, + session: &mut DiscoverySession, + events: &mut Vec, + ) -> bool { + let Some(candidate) = upnp_candidate else { + return false; + }; + let already_present = session + .discovered_candidates + .iter() + .any(|existing| existing.address == candidate.address); + if already_present { + return false; + } + session.discovered_candidates.push(candidate.clone()); + session.statistics.local_candidates_found += 1; + events.push(DiscoveryEvent::LocalCandidateDiscovered { + candidate: candidate.to_candidate_address(), + }); + debug!( + "Added UPnP-mapped public address {} as PortMapped candidate", + candidate.address + ); + true + } + /// Discover local network interface candidates synchronously pub fn discover_local_candidates(&mut self) -> Result, DiscoveryError> { // Start interface scan @@ -816,6 +911,12 @@ impl CandidateDiscoveryManager { } }); + // Snapshot the current UPnP mapping (if any) once per poll — + // we will publish it to the session below alongside the + // bound address. Computed before any session borrows so the + // borrow checker is happy. + let upnp_candidate = self.upnp_candidate(); + if let Some(bound_addr) = bound_candidate { if let Some(session) = self.active_sessions.get_mut(&session_id) { let already_present = session @@ -841,6 +942,12 @@ impl CandidateDiscoveryManager { bound_addr, session_id ); } + + Self::try_publish_upnp_candidate( + upnp_candidate.as_ref(), + session, + &mut all_events, + ); } } @@ -896,6 +1003,23 @@ impl CandidateDiscoveryManager { } } + // Surface the UPnP mapping (if any) at scan completion. + // Re-snapshot here because the mapping may have become + // available between the early-promotion site above and + // this point. The new snapshot lives in a local because + // `try_publish_upnp_candidate` cannot borrow `self` + // while we hold a mutable session reference. + let upnp_candidate_now = self.upnp_candidate(); + if let Some(session) = self.active_sessions.get_mut(&session_id) { + if Self::try_publish_upnp_candidate( + upnp_candidate_now.as_ref(), + session, + &mut all_events, + ) { + candidates_added += 1; + } + } + // Process discovered interfaces // Get the bound port to use for interface addresses (they come with port 0) let bound_port = self.config.bound_address.map(|a| a.port()).unwrap_or(9000); @@ -1008,6 +1132,7 @@ impl CandidateDiscoveryManager { } }); + let upnp_candidate_now = self.upnp_candidate(); if let Some(session) = self.active_sessions.get_mut(&session_id) { if let Some(bound_addr) = bound_candidate { let already_present = session @@ -1035,6 +1160,12 @@ impl CandidateDiscoveryManager { } } + Self::try_publish_upnp_candidate( + upnp_candidate_now.as_ref(), + session, + &mut all_events, + ); + let final_candidates: Vec = session .discovered_candidates .iter() @@ -1632,6 +1763,7 @@ pub mod test_utils { #[cfg(test)] mod tests { use super::*; + use crate::upnp::{UpnpState, UpnpStateRx}; fn create_test_manager() -> CandidateDiscoveryManager { CandidateDiscoveryManager::new(DiscoveryConfig::test_default()) @@ -2453,4 +2585,101 @@ mod tests { Err(CandidateValidationError::IPv4MappedAddress) )); } + + #[test] + fn upnp_mapped_state_surfaces_port_mapped_candidate() { + let mut manager = create_test_manager(); + let session_id = test_session_id(); + + // Pin the UPnP state to Mapped. The address must look public to + // pass downstream candidate validation; 1.1.1.1 is outside every + // reserved range. + let external: SocketAddr = "1.1.1.1:42000".parse().unwrap(); + manager.set_upnp_state_rx(UpnpStateRx::for_test(UpnpState::Mapped { + external, + lease_expires_at: Instant::now() + Duration::from_secs(3600), + })); + + manager + .start_discovery(session_id, vec![]) + .expect("start_discovery should succeed in test"); + + // Drive the local-scanning poll loop until the session reaches + // Completed or we exhaust the test budget. The poll path adds + // both the bound address and the UPnP candidate, then transitions + // the session to Completed once the local interface scan finishes. + let mut events = Vec::new(); + for _ in 0..50 { + events.extend(manager.poll(Instant::now())); + let phase = manager + .active_sessions + .get(&session_id) + .map(|s| s.current_phase.clone()); + if matches!(phase, Some(DiscoveryPhase::Completed { .. })) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + let session = manager + .active_sessions + .get(&session_id) + .expect("session should still exist after polling"); + let port_mapped: Vec<_> = session + .discovered_candidates + .iter() + .filter(|c| matches!(c.source, DiscoverySourceType::PortMapped)) + .collect(); + assert_eq!( + port_mapped.len(), + 1, + "exactly one PortMapped candidate should be surfaced, got {port_mapped:?}", + ); + assert_eq!(port_mapped[0].address, external); + assert_eq!( + port_mapped[0].priority, PORT_MAPPED_DISCOVERY_PRIORITY, + "PortMapped candidate should use the documented priority slot" + ); + + let saw_event = events.iter().any(|e| { + matches!( + e, + DiscoveryEvent::LocalCandidateDiscovered { candidate } + if candidate.address == external + ) + }); + assert!( + saw_event, + "LocalCandidateDiscovered event should be emitted for the UPnP mapping" + ); + } + + #[test] + fn upnp_unavailable_state_does_not_add_candidate() { + let mut manager = create_test_manager(); + let session_id = test_session_id(); + manager.set_upnp_state_rx(UpnpStateRx::for_test(UpnpState::Unavailable)); + + manager + .start_discovery(session_id, vec![]) + .expect("start_discovery should succeed in test"); + + for _ in 0..20 { + manager.poll(Instant::now()); + std::thread::sleep(Duration::from_millis(20)); + } + + let session = manager + .active_sessions + .get(&session_id) + .expect("session should still exist"); + let any_port_mapped = session + .discovered_candidates + .iter() + .any(|c| matches!(c.source, DiscoverySourceType::PortMapped)); + assert!( + !any_port_mapped, + "Unavailable UPnP state must not contribute candidates" + ); + } } diff --git a/src/connection/nat_traversal.rs b/src/connection/nat_traversal.rs index ef9fb4565..5067ed10e 100644 --- a/src/connection/nat_traversal.rs +++ b/src/connection/nat_traversal.rs @@ -95,6 +95,11 @@ pub enum CandidateSource { Peer, /// Generated prediction for symmetric NAT Predicted, + /// Public address obtained via a router-side port mapping + /// (e.g. UPnP IGD AddPortMapping). Treated like a server-reflexive + /// candidate but with higher confidence because the gateway has + /// explicitly committed to forwarding the port for the lease duration. + PortMapped, } /// Current state of a candidate address #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -296,6 +301,10 @@ fn classify_candidate_type(source: CandidateSource) -> CandidateType { CandidateSource::Observed { .. } => CandidateType::ServerReflexive, CandidateSource::Peer => CandidateType::PeerReflexive, CandidateSource::Predicted => CandidateType::ServerReflexive, // Symmetric NAT prediction + // Port-mapped candidates are reflexive — they describe our public + // address as the gateway sees it, just with a deterministic guarantee + // that the gateway will forward traffic for the lease duration. + CandidateSource::PortMapped => CandidateType::ServerReflexive, } } /// Determine pair type from individual candidate types diff --git a/src/lib.rs b/src/lib.rs index 7415fa8f0..3f98961a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -197,6 +197,12 @@ mod token; mod token_memory_cache; /// Zero-cost tracing and event logging system pub mod tracing; +/// Best-effort UPnP IGD port mapping for NAT traversal assistance. +/// +/// This module is feature-gated behind `upnp` (enabled by default). When +/// disabled, [`UpnpMappingService`] is still present but is a no-op stub +/// that always reports [`UpnpState::Unavailable`]. +pub mod upnp; // Public modules with new structure /// Constrained protocol engine for low-bandwidth transports (BLE, LoRa) diff --git a/src/nat_traversal_api.rs b/src/nat_traversal_api.rs index f039af353..d1858c189 100644 --- a/src/nat_traversal_api.rs +++ b/src/nat_traversal_api.rs @@ -342,6 +342,14 @@ pub struct NatTraversalEndpoint { /// Tracks when each connection was first observed as closed. /// Used to enforce a grace period before removing dead connections. closed_at: dashmap::DashMap, + /// Best-effort UPnP IGD port mapping service. + /// + /// The endpoint is the sole owner of the service — the discovery + /// manager only holds a [`crate::upnp::UpnpStateRx`] read handle — + /// so [`Self::shutdown`] can `take()` the service and call + /// [`crate::upnp::UpnpMappingService::shutdown`] for graceful + /// teardown including the gateway-side `DeletePortMapping` request. + upnp_service: parking_lot::Mutex>, } /// Configuration for NAT traversal behavior @@ -483,6 +491,18 @@ pub struct NatTraversalConfig { /// Default: `false` #[serde(default)] pub allow_loopback: bool, + + /// Best-effort UPnP IGD port mapping configuration. + /// + /// When enabled, the endpoint asks the local Internet Gateway Device + /// (UPnP-capable router) to forward its UDP port. The mapping is + /// surfaced as a high-priority NAT traversal candidate when the + /// gateway cooperates, and silently degrades to a no-op when the + /// gateway is absent, has UPnP disabled, or refuses the request. + /// + /// Default: enabled with a one-hour lease. + #[serde(default)] + pub upnp: crate::upnp::UpnpConfig, } fn default_max_message_size() -> usize { @@ -1068,6 +1088,7 @@ impl Default for NatTraversalConfig { transport_registry: None, // Use direct UDP binding by default max_message_size: crate::unified_config::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: false, + upnp: crate::upnp::UpnpConfig::default(), } } } @@ -1265,11 +1286,25 @@ impl NatTraversalEndpoint { let (inner_endpoint, event_tx, event_rx, local_addr, relay_server_config) = Self::create_inner_endpoint(&config, token_store, registry_ref, None).await?; - // Update discovery manager with the actual bound address + // Spawn the best-effort UPnP service against the actual bound port + // before installing the read handle on the discovery manager. The + // service starts a background task that probes the local IGD + // gateway and never blocks endpoint construction — failure + // transitions to `Unavailable` and is invisible to the rest of + // the endpoint. The endpoint owns the service exclusively so + // shutdown can reclaim it for graceful unmap. + let upnp_service = + crate::upnp::UpnpMappingService::start(local_addr.port(), config.upnp.clone()); + let upnp_state_rx = upnp_service.subscribe(); + + // Update discovery manager with the actual bound address and + // attach the UPnP read handle so port-mapped candidates flow + // through local-phase scans. { // parking_lot::Mutex doesn't poison - no need for map_err let mut discovery = discovery_manager.lock(); discovery.set_bound_address(local_addr); + discovery.set_upnp_state_rx(upnp_state_rx); info!( "Updated discovery manager with bound address: {}", local_addr @@ -1378,6 +1413,7 @@ impl NatTraversalEndpoint { handshake_tx: hs_tx, handshake_rx: TokioMutex::new(hs_rx), closed_at: dashmap::DashMap::new(), + upnp_service: parking_lot::Mutex::new(Some(upnp_service)), }; // Multi-transport listening: Spawn receive tasks for all online transports @@ -1674,11 +1710,25 @@ impl NatTraversalEndpoint { let (inner_endpoint, event_tx, event_rx, local_addr, relay_server_config) = Self::create_inner_endpoint(&config, token_store, registry_ref, quinn_socket).await?; - // Update discovery manager with the actual bound address + // Spawn the best-effort UPnP service against the actual bound port + // before installing the read handle on the discovery manager. The + // service starts a background task that probes the local IGD + // gateway and never blocks endpoint construction — failure + // transitions to `Unavailable` and is invisible to the rest of + // the endpoint. The endpoint owns the service exclusively so + // shutdown can reclaim it for graceful unmap. + let upnp_service = + crate::upnp::UpnpMappingService::start(local_addr.port(), config.upnp.clone()); + let upnp_state_rx = upnp_service.subscribe(); + + // Update discovery manager with the actual bound address and + // attach the UPnP read handle so port-mapped candidates flow + // through local-phase scans. { // parking_lot::Mutex doesn't poison - no need for map_err let mut discovery = discovery_manager.lock(); discovery.set_bound_address(local_addr); + discovery.set_upnp_state_rx(upnp_state_rx); info!( "Updated discovery manager with bound address: {}", local_addr @@ -1787,6 +1837,7 @@ impl NatTraversalEndpoint { handshake_tx: hs_tx, handshake_rx: TokioMutex::new(hs_rx), closed_at: dashmap::DashMap::new(), + upnp_service: parking_lot::Mutex::new(Some(upnp_service)), }; // Multi-transport listening: Spawn receive tasks for all online transports @@ -4450,6 +4501,17 @@ impl NatTraversalEndpoint { self.incoming_notify.notify_waiters(); self.shutdown_notify.notify_waiters(); + // Best-effort UPnP teardown. The endpoint is the sole owner of + // the service (the discovery manager only holds a read-only + // `UpnpStateRx`), so we can move it out and call its async + // shutdown directly. Failures are swallowed inside the service — + // the lease is the ultimate safety net. The mutex guard is + // dropped before the await so the resulting future stays `Send`. + let upnp_service = self.upnp_service.lock().take(); + if let Some(service) = upnp_service { + service.shutdown().await; + } + // Close all active connections // DashMap: collect addresses then remove them one by one let addrs: Vec = self.connections.iter().map(|e| *e.key()).collect(); @@ -4654,6 +4716,13 @@ impl NatTraversalEndpoint { // which drives the coordinator-mediated PUNCH_ME_NOW flow whose // server-side helpers (`send_coordination_request_with_peer_id`, etc.) // are defined later in this file. + // + // The PortMapped `CandidateSource` variant introduced by the UPnP + // work still flows through the production pairing path unchanged: + // `classify_candidate_type` in `crate::connection::nat_traversal` + // maps `CandidateSource::PortMapped` to `CandidateType::ServerReflexive`, + // which is what the live ICE-style priority formula in that module + // consumes. No additional plumbing is required here. /// Attempt connection to a specific candidate address fn attempt_connection_to_candidate( diff --git a/src/unified_config.rs b/src/unified_config.rs index 63f9b7c2f..3886b737e 100644 --- a/src/unified_config.rs +++ b/src/unified_config.rs @@ -144,6 +144,12 @@ pub struct NatConfig { /// /// Default: `false` pub allow_loopback: bool, + + /// Best-effort UPnP IGD port mapping configuration. When enabled + /// (default), the endpoint asks the local router to forward its UDP + /// port and surfaces the resulting public address as a high-priority + /// NAT traversal candidate. Failure is silent and non-fatal. + pub upnp: crate::upnp::UpnpConfig, } impl Default for NatConfig { @@ -157,6 +163,7 @@ impl Default for NatConfig { max_concurrent_attempts: 3, prefer_rfc_nat_traversal: true, allow_loopback: false, + upnp: crate::upnp::UpnpConfig::default(), } } } @@ -310,6 +317,7 @@ impl P2pConfig { transport_registry: Some(Arc::new(self.transport_registry.clone())), max_message_size: self.max_message_size, allow_loopback: self.nat.allow_loopback, + upnp: self.nat.upnp.clone(), } } diff --git a/src/upnp.rs b/src/upnp.rs new file mode 100644 index 000000000..0e356221b --- /dev/null +++ b/src/upnp.rs @@ -0,0 +1,767 @@ +// Copyright 2024 Saorsa Labs Ltd. +// +// This Saorsa Network Software is licensed under the General Public License (GPL), version 3. +// Please see the file LICENSE-GPL, or visit for the full text. + +//! Best-effort UPnP IGD port mapping. +//! +//! This module asks the local Internet Gateway Device (typically a home +//! router) to forward a single UDP port to our endpoint. When successful, +//! the gateway provides a deterministic public `ip:port` reachable from +//! the open internet, which is then surfaced as a high-priority NAT +//! traversal candidate alongside locally-discovered and peer-observed +//! addresses. +//! +//! # Best-effort contract +//! +//! Everything in this module is **strictly additive**. The endpoint must +//! behave identically to a non-UPnP build when the gateway: +//! +//! * does not exist (no router on the LAN, or it does not speak SSDP), +//! * has UPnP IGD disabled in its administrative settings, +//! * supports UPnP but refuses the mapping request, +//! * accepts the request but later forgets it / reboots / changes IPs. +//! +//! Concretely this means: +//! +//! 1. [`UpnpMappingService::start`] never returns an error and never blocks +//! on network I/O — it spawns a background task and returns immediately. +//! 2. All failures are swallowed and logged at `debug` level. The only +//! `info` log line is the success path. +//! 3. Discovery is single-shot per service lifetime. A router that did not +//! answer once is left alone for the rest of the session — there is no +//! periodic re-probe. +//! 4. The lease is finite (one hour by default), so a crashed process +//! cannot leak a permanent mapping on the gateway. +//! +//! Callers consume the service by polling [`UpnpMappingService::current`] +//! when they want the most recent state. The poll is a lock-free atomic +//! load on the underlying `tokio::sync::watch` channel, so it is cheap to +//! call from the candidate discovery hot path. + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use tokio::sync::watch; +use tokio::task::JoinHandle; +#[cfg(feature = "upnp")] +use tracing::{debug, info, warn}; + +/// Default lease duration requested from the gateway. +/// +/// One hour balances two concerns: short enough that a crashed process +/// cannot leak a permanent mapping on the router, long enough that the +/// refresh task does not generate noticeable network churn. +const DEFAULT_LEASE: Duration = Duration::from_secs(3600); + +/// Default budget for the initial gateway discovery probe. +/// +/// SSDP M-SEARCH multicasts and waits for responses; without a hard +/// deadline a non-UPnP LAN would force the background task to wait the +/// full SSDP timeout (~10s) before giving up. Two seconds is enough for +/// any cooperating gateway on the same broadcast domain. +const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2); + +/// Best-effort budget for the cleanup `DeletePortMapping` request issued +/// during graceful shutdown. The lease is the ultimate safety net, so +/// blocking shutdown waiting for an unresponsive router would be wrong. +#[cfg(feature = "upnp")] +const SHUTDOWN_UNMAP_BUDGET: Duration = Duration::from_millis(500); + +/// Configuration for [`UpnpMappingService`]. +/// +/// Defaults are tuned for the common case (residential broadband + a +/// consumer router) and should rarely need to be overridden. Use +/// [`UpnpConfig::disabled`] to explicitly opt out at runtime. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpnpConfig { + /// Master switch. When `false`, [`UpnpMappingService::start`] returns + /// a service that is permanently in [`UpnpState::Unavailable`] and + /// performs no network I/O. + pub enabled: bool, + + /// Lease duration to request from the gateway. The refresh task will + /// renew at half this interval. + #[serde(with = "duration_secs")] + pub lease_duration: Duration, + + /// Maximum time to wait for the initial gateway discovery probe. + /// After this deadline elapses with no gateway response, the service + /// transitions to [`UpnpState::Unavailable`] and stops trying. + #[serde(with = "duration_millis")] + pub discovery_timeout: Duration, +} + +impl Default for UpnpConfig { + fn default() -> Self { + Self { + enabled: true, + lease_duration: DEFAULT_LEASE, + discovery_timeout: DEFAULT_DISCOVERY_TIMEOUT, + } + } +} + +impl UpnpConfig { + /// Construct a configuration that permanently disables UPnP. + pub const fn disabled() -> Self { + Self { + enabled: false, + lease_duration: DEFAULT_LEASE, + discovery_timeout: DEFAULT_DISCOVERY_TIMEOUT, + } + } +} + +/// Snapshot of the UPnP mapping state at a point in time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpnpState { + /// Initial discovery is still in flight or has not yet started. + Probing, + /// No usable gateway is available for this session. This is a sticky + /// state — once entered, the service stays here until shut down. + /// Reached when SSDP discovery times out, the gateway refuses the + /// mapping, returns a non-public external IP, or otherwise fails. + Unavailable, + /// Gateway is forwarding `external` to our local UDP port. + Mapped { + /// Public address that remote peers can dial to reach this + /// endpoint via the gateway-managed mapping. + external: SocketAddr, + /// Wall-clock instant at which the current lease expires. The + /// background refresh task renews the lease before this point; + /// callers should treat the value as informational. + lease_expires_at: Instant, + }, +} + +/// Background service that maintains a single UDP UPnP mapping for the +/// endpoint's local port. +/// +/// Construct with [`UpnpMappingService::start`]. Read state with +/// [`UpnpMappingService::current`] or hand a [`UpnpStateRx`] to consumers +/// via [`UpnpMappingService::subscribe`]. Tear down with +/// [`UpnpMappingService::shutdown`] (the implementation also has a +/// best-effort `Drop` fallback for the panic path). +pub struct UpnpMappingService { + state: watch::Receiver, + inner: Arc, +} + +/// Read-only handle to the current [`UpnpState`]. +/// +/// Cloneable, lock-free, and decoupled from service ownership: callers +/// that only need to observe the mapping (for example, the candidate +/// discovery manager) take a `UpnpStateRx` instead of an +/// `Arc`, leaving the endpoint as the sole owner of +/// the service so graceful shutdown can reclaim and unmap it. +#[derive(Clone)] +pub struct UpnpStateRx { + inner: watch::Receiver, +} + +impl UpnpStateRx { + /// Lock-free snapshot of the most recent state. + pub fn current(&self) -> UpnpState { + self.inner.borrow().clone() + } + + /// Test-only constructor that pins the receiver to a fixed state. + #[cfg(test)] + pub(crate) fn for_test(state: UpnpState) -> Self { + let (_tx, rx) = watch::channel(state); + Self { inner: rx } + } +} + +struct ServiceInner { + shutdown: tokio::sync::Notify, + /// Once the background task observes the shutdown notification it + /// stores the active mapping (if any) here so [`UpnpMappingService::shutdown`] + /// can issue the final `DeletePortMapping` from the caller's task. + /// We deliberately keep the cleanup off the background task so that + /// dropping the runtime in tests does not block on the unmap RPC. + last_mapping: parking_lot::Mutex>, + handle: parking_lot::Mutex>>, +} + +#[derive(Debug, Clone)] +#[cfg_attr(not(feature = "upnp"), allow(dead_code))] +struct ActiveMapping { + external_port: u16, + gateway: GatewayHandle, +} + +impl UpnpMappingService { + /// Spawn the UPnP service for `local_udp_port`. + /// + /// This is infallible by design — even when UPnP is unsupported on + /// the host, this returns a service stuck in [`UpnpState::Unavailable`]. + /// The returned service starts in [`UpnpState::Probing`] when enabled. + pub fn start(local_udp_port: u16, config: UpnpConfig) -> Self { + let (tx, rx) = watch::channel(UpnpState::Probing); + let inner = Arc::new(ServiceInner { + shutdown: tokio::sync::Notify::new(), + last_mapping: parking_lot::Mutex::new(None), + handle: parking_lot::Mutex::new(None), + }); + + if !config.enabled { + // Permanently unavailable — never touches the network. + let _ = tx.send(UpnpState::Unavailable); + return Self { state: rx, inner }; + } + + let handle = spawn_background_task(local_udp_port, config, tx, Arc::clone(&inner)); + *inner.handle.lock() = handle; + Self { state: rx, inner } + } + + /// Lock-free snapshot of the most recent state. + /// + /// Cheap enough to call from a discovery hot path on every poll. + pub fn current(&self) -> UpnpState { + self.state.borrow().clone() + } + + /// Clone the watch receiver so callers can poll state without owning + /// a reference to the service itself. + /// + /// Use this when the consumer only needs to read the current mapping + /// (for example, the candidate discovery manager) — it keeps service + /// lifetime cleanly owned by the endpoint and lets graceful shutdown + /// reclaim the unique `Arc` for `try_unwrap`. + pub fn subscribe(&self) -> UpnpStateRx { + UpnpStateRx { + inner: self.state.clone(), + } + } + + /// Best-effort graceful teardown. + /// + /// Signals the background task to stop, then attempts a single + /// `DeletePortMapping` against the gateway with a 500ms budget. + /// All errors are swallowed — if the router has gone away, the lease + /// expires naturally. Mutex guards are released before the awaits so + /// the resulting future stays `Send` for callers running on a + /// multi-threaded tokio runtime. + pub async fn shutdown(self) { + self.inner.shutdown.notify_waiters(); + + let handle = self.inner.handle.lock().take(); + if let Some(handle) = handle { + handle.abort(); + let _ = handle.await; + } + + let active = self.inner.last_mapping.lock().take(); + if let Some(active) = active { + best_effort_unmap(active).await; + } + } +} + +impl Drop for UpnpMappingService { + fn drop(&mut self) { + // Crash-path safety: notify any background task and abort it. + // We deliberately do *not* attempt async unmap here — the lease + // is the ultimate safety net. + self.inner.shutdown.notify_waiters(); + if let Some(handle) = self.inner.handle.lock().take() { + handle.abort(); + } + } +} + +/// Returns true if `addr` looks like a publicly routable IP address. +/// +/// We require this check because misbehaving routers will sometimes return +/// their LAN-side address as the "external" IP via `GetExternalIP`. Trusting +/// such a value would poison NAT traversal candidate selection — the +/// endpoint would advertise an unreachable RFC1918 address as if it were +/// public. +#[cfg_attr(not(feature = "upnp"), allow(dead_code))] +pub(crate) fn is_plausibly_public(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(v4) => is_plausibly_public_v4(v4), + IpAddr::V6(v6) => is_plausibly_public_v6(v6), + } +} + +#[cfg_attr(not(feature = "upnp"), allow(dead_code))] +fn is_plausibly_public_v4(addr: Ipv4Addr) -> bool { + if addr.is_loopback() + || addr.is_unspecified() + || addr.is_broadcast() + || addr.is_multicast() + || addr.is_link_local() + || addr.is_documentation() + { + return false; + } + if addr.is_private() { + return false; + } + // CGNAT range (RFC 6598) — addresses here are NAT'd by the carrier and + // are not directly reachable from the public internet, so a UPnP + // mapping against a 100.64/10 "external" IP is useless. + let octets = addr.octets(); + if octets[0] == 100 && (64..=127).contains(&octets[1]) { + return false; + } + true +} + +#[cfg_attr(not(feature = "upnp"), allow(dead_code))] +fn is_plausibly_public_v6(addr: std::net::Ipv6Addr) -> bool { + // Reject the standard garbage: loopback, unspecified, multicast, + // link-local unicast, documentation. Anything else (global unicast, + // ULA) is acceptable — ULAs are not routable but a misconfigured + // gateway returning a ULA is rare enough that we let the candidate + // validator catch it later. + // + // Mirrors the IPv4 classifier's rejection of RFC 5737 documentation + // space so a misbehaving router cannot poison candidate discovery by + // returning an RFC 3849 `2001:db8::/32` address as its "external" IP. + !(addr.is_loopback() + || addr.is_unspecified() + || addr.is_multicast() + || addr.is_unicast_link_local() + || is_ipv6_documentation(addr)) +} + +/// First 16-bit group of the RFC 3849 IPv6 documentation prefix +/// `2001:db8::/32`. +const IPV6_DOCUMENTATION_PREFIX_HI: u16 = 0x2001; +/// Second 16-bit group of the RFC 3849 IPv6 documentation prefix +/// `2001:db8::/32`. +const IPV6_DOCUMENTATION_PREFIX_LO: u16 = 0x0db8; + +/// RFC 3849 documentation prefix — `2001:db8::/32`. +/// +/// Stdlib does not expose an `is_documentation` helper for `Ipv6Addr`, so +/// we match the prefix manually. Kept separate to mirror the v4 +/// `Ipv4Addr::is_documentation` call path at the classifier site. +#[cfg_attr(not(feature = "upnp"), allow(dead_code))] +fn is_ipv6_documentation(addr: std::net::Ipv6Addr) -> bool { + let segments = addr.segments(); + segments[0] == IPV6_DOCUMENTATION_PREFIX_HI && segments[1] == IPV6_DOCUMENTATION_PREFIX_LO +} + +// --------------------------------------------------------------------------- +// Backend selection: real `igd-next` implementation when the `upnp` feature +// is enabled, no-op stub otherwise. Both backends share the public types +// above so call sites do not need to be feature-gated. +// --------------------------------------------------------------------------- + +#[cfg(feature = "upnp")] +mod backend { + use super::*; + use igd_next::PortMappingProtocol; + use igd_next::SearchOptions; + use igd_next::aio::Gateway as GenericGateway; + use igd_next::aio::tokio::{Tokio, search_gateway}; + + pub(super) type GatewayHandle = Arc>; + + /// Description sent to the gateway. Most consumer routers expose this + /// in the admin UI's port-forwarding table. + const MAPPING_DESCRIPTION: &str = concat!("saorsa-transport/", env!("CARGO_PKG_VERSION")); + + pub(super) fn spawn_background_task( + local_port: u16, + config: UpnpConfig, + tx: watch::Sender, + inner: Arc, + ) -> Option> { + let handle = tokio::spawn(async move { + run_service(local_port, config, tx, inner).await; + }); + Some(handle) + } + + async fn run_service( + local_port: u16, + config: UpnpConfig, + tx: watch::Sender, + inner: Arc, + ) { + let gateway = match discover_gateway(config.discovery_timeout).await { + Some(gw) => Arc::new(gw), + None => { + let _ = tx.send(UpnpState::Unavailable); + return; + } + }; + + // Validate the gateway's claimed external IP before trusting any + // mapping it offers. A router that returns its LAN address here is + // misconfigured and unsafe to use — surfacing such an "external" + // address as a NAT traversal candidate would actively break peers. + let external_ip = match gateway.get_external_ip().await { + Ok(ip) => ip, + Err(err) => { + debug!(error = %err, "upnp: get_external_ip failed"); + let _ = tx.send(UpnpState::Unavailable); + return; + } + }; + if !is_plausibly_public(external_ip) { + warn!( + external_ip = %external_ip, + "upnp: gateway returned a non-public external IP, refusing to use" + ); + let _ = tx.send(UpnpState::Unavailable); + return; + } + + let local_addr = local_socket_for_mapping(local_port); + let mapped_port = + match request_mapping(&gateway, local_addr, local_port, config.lease_duration).await { + Some(port) => port, + None => { + let _ = tx.send(UpnpState::Unavailable); + return; + } + }; + + let external = SocketAddr::new(external_ip, mapped_port); + let mut lease_expires_at = Instant::now() + config.lease_duration; + info!( + external = %external, + lease_secs = config.lease_duration.as_secs(), + "upnp: gateway mapping active" + ); + + // Record the active mapping so the shutdown path can clean it up. + *inner.last_mapping.lock() = Some(ActiveMapping { + external_port: mapped_port, + gateway: Arc::clone(&gateway), + }); + + let _ = tx.send(UpnpState::Mapped { + external, + lease_expires_at, + }); + + // Refresh loop: re-request the mapping at half the lease interval. + // Failure here is not fatal — we transition to Unavailable, leave + // the existing mapping to expire on its own, and exit the task. + loop { + let refresh_in = (config.lease_duration / 2).max(Duration::from_secs(30)); + tokio::select! { + () = inner.shutdown.notified() => { + return; + } + () = tokio::time::sleep(refresh_in) => {} + } + + match request_mapping(&gateway, local_addr, mapped_port, config.lease_duration).await { + Some(port) if port == mapped_port => { + lease_expires_at = Instant::now() + config.lease_duration; + let _ = tx.send(UpnpState::Mapped { + external, + lease_expires_at, + }); + } + _ => { + debug!("upnp: lease refresh failed, marking unavailable"); + *inner.last_mapping.lock() = None; + let _ = tx.send(UpnpState::Unavailable); + return; + } + } + } + } + + async fn discover_gateway(timeout: Duration) -> Option> { + let opts = SearchOptions { + timeout: Some(timeout), + ..Default::default() + }; + match tokio::time::timeout(timeout, search_gateway(opts)).await { + Ok(Ok(gateway)) => Some(gateway), + Ok(Err(err)) => { + debug!(error = %err, "upnp: gateway discovery failed"); + None + } + Err(_) => { + debug!("upnp: gateway discovery timed out"); + None + } + } + } + + /// Request a UDP mapping for `local_addr`, preferring port preservation. + /// + /// Tries `add_port(preferred_external)` first because matching the + /// internal port keeps the mapped candidate aligned with what peers + /// will see via OBSERVED_ADDRESS. Falls back to `add_any_port` so the + /// gateway can pick a free port if the preferred one is taken. + async fn request_mapping( + gateway: &GenericGateway, + local_addr: SocketAddr, + preferred_external: u16, + lease: Duration, + ) -> Option { + let lease_secs = u32::try_from(lease.as_secs()).unwrap_or(u32::MAX); + + match gateway + .add_port( + PortMappingProtocol::UDP, + preferred_external, + local_addr, + lease_secs, + MAPPING_DESCRIPTION, + ) + .await + { + Ok(()) => return Some(preferred_external), + Err(err) => { + debug!( + preferred_external, + error = %err, + "upnp: add_port for preferred external failed, falling back to add_any_port" + ); + } + } + + match gateway + .add_any_port( + PortMappingProtocol::UDP, + local_addr, + lease_secs, + MAPPING_DESCRIPTION, + ) + .await + { + Ok(port) => Some(port), + Err(err) => { + debug!(error = %err, "upnp: add_any_port failed"); + None + } + } + } + + /// Build a `SocketAddr` for the gateway to forward traffic to. + /// + /// `igd-next` requires an explicit local IP rather than `0.0.0.0`, + /// because the gateway needs to know which LAN host owns the mapping. + /// We pick the first IPv4 address that matches the egress route to the + /// gateway by relying on the OS-default outbound socket trick: connect + /// a UDP socket to a public address and read its local IP. The remote + /// address is never actually contacted. + /// + /// This uses `std::net::UdpSocket` rather than `tokio::net::UdpSocket` + /// because both `bind` and `connect` on UDP are pure kernel route + /// lookups — there is no wire I/O, so the executor thread is not + /// actually blocked. Called once per session at the top of the + /// background task, before the real SSDP discovery begins. + fn local_socket_for_mapping(local_port: u16) -> SocketAddr { + // 192.0.2.1 (TEST-NET-1) is RFC 5737 documentation space — packets + // are not routed but the kernel will still pick the correct + // outbound interface for the route lookup. + let probe = std::net::UdpSocket::bind("0.0.0.0:0") + .and_then(|sock| { + sock.connect("192.0.2.1:9")?; + sock.local_addr() + }) + .map(|addr| addr.ip()); + + let local_ip = match probe { + Ok(IpAddr::V4(v4)) if !v4.is_unspecified() => IpAddr::V4(v4), + // UPnP IGD v1 only deals in IPv4 mappings; if the egress route + // resolved to IPv6 (or failed entirely) we fall back to the + // unspecified address and let `add_port` reject it. The error + // is logged at `debug` and surfaces as `Unavailable`. + _ => IpAddr::V4(Ipv4Addr::UNSPECIFIED), + }; + SocketAddr::new(local_ip, local_port) + } + + pub(super) async fn best_effort_unmap(active: ActiveMapping) { + let unmap = active + .gateway + .remove_port(PortMappingProtocol::UDP, active.external_port); + match tokio::time::timeout(SHUTDOWN_UNMAP_BUDGET, unmap).await { + Ok(Ok(())) => debug!("upnp: deleted port mapping on shutdown"), + Ok(Err(err)) => debug!(error = %err, "upnp: delete_port_mapping failed on shutdown"), + Err(_) => debug!("upnp: delete_port_mapping timed out on shutdown"), + } + } +} + +#[cfg(not(feature = "upnp"))] +mod backend { + use super::*; + + /// Stub gateway handle used when the `upnp` feature is disabled. + /// Carries no state and is never instantiated at runtime. + pub(super) type GatewayHandle = (); + + pub(super) fn spawn_background_task( + _local_port: u16, + _config: UpnpConfig, + tx: watch::Sender, + _inner: Arc, + ) -> Option> { + // Without the feature we cannot probe a gateway, so transition + // straight to Unavailable and skip spawning a task entirely. + let _ = tx.send(UpnpState::Unavailable); + None + } + + pub(super) async fn best_effort_unmap(_active: ActiveMapping) { + // No backend → nothing to release. + } +} + +use backend::{GatewayHandle, best_effort_unmap, spawn_background_task}; + +// --------------------------------------------------------------------------- +// Serde helpers — keep human-readable units in serialized config files +// without inflicting them on the public API. +// --------------------------------------------------------------------------- + +mod duration_secs { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(value: &Duration, ser: S) -> Result { + ser.serialize_u64(value.as_secs()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result { + let secs = u64::deserialize(de)?; + Ok(Duration::from_secs(secs)) + } +} + +mod duration_millis { + use serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(value: &Duration, ser: S) -> Result { + ser.serialize_u64(value.as_millis() as u64) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result { + let ms = u64::deserialize(de)?; + Ok(Duration::from_millis(ms)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv6Addr; + + #[test] + fn disabled_service_reports_unavailable_immediately() { + let service = UpnpMappingService::start(0, UpnpConfig::disabled()); + assert_eq!(service.current(), UpnpState::Unavailable); + } + + #[test] + fn default_config_is_enabled_with_one_hour_lease() { + let cfg = UpnpConfig::default(); + assert!(cfg.enabled); + assert_eq!(cfg.lease_duration, DEFAULT_LEASE); + assert_eq!(cfg.discovery_timeout, DEFAULT_DISCOVERY_TIMEOUT); + } + + #[test] + fn rejects_rfc1918_addresses_as_external_ip() { + for blocked in [ + Ipv4Addr::new(10, 0, 0, 1), + Ipv4Addr::new(172, 16, 5, 9), + Ipv4Addr::new(192, 168, 1, 254), + ] { + assert!( + !is_plausibly_public(IpAddr::V4(blocked)), + "{blocked} should be rejected as non-public" + ); + } + } + + #[test] + fn rejects_loopback_link_local_and_cgnat() { + assert!(!is_plausibly_public(IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(!is_plausibly_public(IpAddr::V4(Ipv4Addr::UNSPECIFIED))); + assert!(!is_plausibly_public(IpAddr::V4(Ipv4Addr::BROADCAST))); + assert!(!is_plausibly_public(IpAddr::V4(Ipv4Addr::new( + 169, 254, 1, 1 + )))); + assert!(!is_plausibly_public(IpAddr::V4(Ipv4Addr::new( + 100, 64, 0, 1 + )))); + assert!(!is_plausibly_public(IpAddr::V4(Ipv4Addr::new( + 100, 127, 255, 254 + )))); + } + + #[test] + fn accepts_public_ipv4_outside_special_ranges() { + assert!(is_plausibly_public(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); + assert!(is_plausibly_public(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)))); + } + + #[test] + fn rejects_documentation_ranges() { + // RFC 5737 documentation prefixes — must never be advertised as + // a real external IP, regardless of what a misbehaving gateway + // might claim. + assert!(!is_plausibly_public(IpAddr::V4(Ipv4Addr::new( + 192, 0, 2, 1 + )))); + assert!(!is_plausibly_public(IpAddr::V4(Ipv4Addr::new( + 198, 51, 100, 1 + )))); + assert!(!is_plausibly_public(IpAddr::V4(Ipv4Addr::new( + 203, 0, 113, 1 + )))); + } + + #[test] + fn accepts_global_unicast_ipv6_and_rejects_link_local() { + // 2606:4700:4700::1111 is Cloudflare DNS, a real global unicast + // address. Explicitly chosen over 2001:db8::/32 so this test + // exercises the happy path rather than accidentally landing in + // documentation space. + let global = Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111); + let link_local = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1); + assert!(is_plausibly_public(IpAddr::V6(global))); + assert!(!is_plausibly_public(IpAddr::V6(link_local))); + assert!(!is_plausibly_public(IpAddr::V6(Ipv6Addr::LOCALHOST))); + } + + #[test] + fn rejects_ipv6_documentation_range() { + // RFC 3849 `2001:db8::/32` is the IPv6 counterpart of the RFC + // 5737 documentation prefixes. A misbehaving router returning an + // address from this range must never be accepted as an external + // IP, matching the IPv4 `is_documentation()` rejection. + assert!(!is_plausibly_public(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0x0db8, 0, 0, 0, 0, 0, 1 + )))); + assert!(!is_plausibly_public(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0x0db8, 0xdead, 0xbeef, 0, 0, 0, 0x42 + )))); + // A neighbouring /32 (2001:0db9::) is not documentation space + // and must still be accepted. + assert!(is_plausibly_public(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0x0db9, 0, 0, 0, 0, 0, 1 + )))); + } + + #[test] + fn rejects_ipv6_multicast_and_unspecified() { + assert!(!is_plausibly_public(IpAddr::V6(Ipv6Addr::UNSPECIFIED))); + // ff00::/8 — multicast. + assert!(!is_plausibly_public(IpAddr::V6(Ipv6Addr::new( + 0xff02, 0, 0, 0, 0, 0, 0, 1 + )))); + } +} diff --git a/tests/relay_queue_tests.rs b/tests/relay_queue_tests.rs index be5cb402f..20375f1e1 100644 --- a/tests/relay_queue_tests.rs +++ b/tests/relay_queue_tests.rs @@ -55,6 +55,7 @@ mod nat_traversal_api_tests { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: false, + upnp: Default::default(), }; assert_eq!(config.known_peers.len(), 1); @@ -191,6 +192,7 @@ mod functional_tests { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: false, + upnp: Default::default(), }; // May fail due to zero values or other validation @@ -217,6 +219,7 @@ mod functional_tests { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: false, + upnp: Default::default(), }; let result = NatTraversalEndpoint::new(valid_config, None, None).await; @@ -409,6 +412,7 @@ mod performance_tests { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: false, + upnp: Default::default(), }; // Use the config to prevent optimization @@ -479,6 +483,7 @@ mod relay_functionality_tests { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: false, + upnp: Default::default(), }; // This might be accepted or rejected depending on implementation diff --git a/tests/security_regression_tests.rs b/tests/security_regression_tests.rs index 48a66271a..3a8b450c3 100644 --- a/tests/security_regression_tests.rs +++ b/tests/security_regression_tests.rs @@ -37,6 +37,7 @@ fn test_peer_config() -> NatTraversalConfig { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: true, + upnp: Default::default(), } } @@ -61,6 +62,7 @@ fn test_server_config() -> NatTraversalConfig { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: true, + upnp: Default::default(), } } @@ -108,6 +110,7 @@ async fn test_error_handling_no_panic() { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: true, + upnp: Default::default(), }; let result1 = NatTraversalEndpoint::new(config1, None, None).await; @@ -136,6 +139,7 @@ async fn test_error_handling_no_panic() { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: true, + upnp: Default::default(), }; let result2 = NatTraversalEndpoint::new(config2, None, None).await; @@ -227,6 +231,7 @@ async fn test_malformed_config_handling() { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: true, + upnp: Default::default(), }; let result = NatTraversalEndpoint::new(no_peers_config, None, None).await; @@ -256,6 +261,7 @@ async fn test_malformed_config_handling() { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: true, + upnp: Default::default(), }; let result2 = NatTraversalEndpoint::new(extreme_config, None, None).await; @@ -292,6 +298,7 @@ async fn test_input_sanitization() { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: true, + upnp: Default::default(), }; // This should either work or fail gracefully, not exhaust memory or panic @@ -363,6 +370,7 @@ mod specific_regression_tests { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: true, + upnp: Default::default(), }; // Should not panic and should handle random port selection @@ -414,6 +422,7 @@ mod specific_regression_tests { transport_registry: None, max_message_size: saorsa_transport::P2pConfig::DEFAULT_MAX_MESSAGE_SIZE, allow_loopback: true, + upnp: Default::default(), }; // Should not panic, even if configuration is inconsistent