diff --git a/Cargo.lock b/Cargo.lock index c152586..9323249 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,18 @@ dependencies = [ "bytemuck", ] +[[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.1" @@ -187,6 +199,12 @@ version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "castaway" version = "0.2.4" @@ -208,6 +226,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "clap" version = "4.6.2" @@ -314,6 +343,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 = "critical-section" version = "1.2.0" @@ -445,6 +483,17 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "document-features" version = "0.2.12" @@ -552,6 +601,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -607,6 +665,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -649,12 +708,138 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "log", + "rand 0.10.2", + "url", + "xmltree", +] + [[package]] name = "indenter" version = "0.3.4" @@ -765,6 +950,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "litrs" version = "1.0.0" @@ -994,6 +1185,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pest" version = "2.8.7" @@ -1063,7 +1260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.7", ] [[package]] @@ -1100,6 +1297,15 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1142,7 +1348,18 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1151,6 +1368,12 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.2" @@ -1379,7 +1602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1448,6 +1671,7 @@ dependencies = [ "clap", "color-eyre", "dotenv", + "igd-next", "ratatui", "soulseek-rs-lib", ] @@ -1456,6 +1680,12 @@ dependencies = [ name = "soulseek-rs-lib" version = "0.3.0" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1511,6 +1741,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "termina" version = "0.3.3" @@ -1667,6 +1908,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tracing" version = "0.1.44" @@ -1749,6 +2000,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1962,3 +2231,101 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[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 = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index df2f614..a1631b9 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -23,3 +23,4 @@ clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] } ratatui = "0.30.2" color-eyre = "0.6.5" dotenv = "0.15.0" +igd-next = "0.17" diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 19d4368..4ba08ac 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -2,6 +2,7 @@ mod cli; mod config; mod directories; mod models; +mod port_mapping; mod ui; use clap::Parser; @@ -160,6 +161,12 @@ fn main() -> Result<()> { shared_directory, }; + // Best-effort: make ourselves reachable behind a home router so + // firewalled peers can connect back. Kept alive for the session. + let _port_mapper = settings + .enable_listen + .then(|| port_mapping::PortMapper::spawn(settings.listen_port)); + let mut client = Client::with_settings(settings); client.connect().map_err(|e| { color_eyre::eyre::eyre!("Failed to connect: {}", e) @@ -193,6 +200,9 @@ fn main() -> Result<()> { fn browse_user(settings: &ClientSettings, target: &str) -> Result<()> { use std::time::Instant; + let _port_mapper = settings + .enable_listen + .then(|| port_mapping::PortMapper::spawn(settings.listen_port)); let mut client = Client::with_settings(settings.clone()); client .connect() @@ -378,6 +388,10 @@ fn search_and_download(config: SearchConfig) -> Result<()> { shared_directory: config.shared_directory.clone(), }; + let _port_mapper = settings + .enable_listen + .then(|| port_mapping::PortMapper::spawn(settings.listen_port)); + let mut client = Client::with_settings(settings); client .connect() diff --git a/soulseek-rs/src/port_mapping/mod.rs b/soulseek-rs/src/port_mapping/mod.rs new file mode 100644 index 0000000..2d5459d --- /dev/null +++ b/soulseek-rs/src/port_mapping/mod.rs @@ -0,0 +1,382 @@ +//! Best-effort automatic port mapping so a client behind a home router becomes +//! reachable for incoming peer connections (browse/download of firewalled peers +//! and the server-brokered connect-back both need this). +//! +//! Tries UPnP-IGD (via the `igd-next` crate) first, then hand-rolled NAT-PMP +//! (see [`nat_pmp`]). Everything is best-effort: any failure is logged and the +//! program continues (the user can still reach non-firewalled peers, and can +//! port-forward manually). The mapping is renewed on a timer and removed on +//! shutdown. + +mod nat_pmp; + +use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use igd_next::{PortMappingProtocol, SearchOptions, search_gateway}; +use soulseek_rs::utils::logger::{self, LogLevel}; + +const MAPPING_DESCRIPTION: &str = "soulseek-rs"; +/// Lease we request; both backends renew at half this interval. +const LEASE_SECS: u32 = nat_pmp::RECOMMENDED_LIFETIME; +/// Interactive NAT-PMP retransmit budget (~3.75s worst case), not the full 9. +const NATPMP_ATTEMPTS: u32 = 4; + +/// A live port mapping and its renewal thread. Dropping it stops renewal and +/// removes the mapping (best-effort). +pub struct PortMapper { + stop: Arc, + handle: Option>, +} + +impl PortMapper { + /// Spawn a background best-effort mapper for TCP `port`. Returns immediately; + /// discovery/mapping happens on the thread so startup never blocks. + #[must_use] + pub fn spawn(port: u16) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = stop.clone(); + let handle = thread::Builder::new() + .name("port-mapper".into()) + .spawn(move || run(port, &thread_stop)) + .ok(); + Self { stop, handle } + } +} + +impl Drop for PortMapper { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +/// Which backend created the active mapping, carrying what's needed to renew and +/// remove it. +enum Backend { + Upnp { + gateway: Box, + local_addr: SocketAddr, + external_port: u16, + }, + NatPmp { + gateway: SocketAddr, + internal_port: u16, + }, +} + +fn run(port: u16, stop: &AtomicBool) { + let Some(local_ip) = local_lan_ip() else { + warn( + "could not determine this machine's LAN IP; skipping automatic \ + port mapping (you may need to forward the port manually)", + ); + return; + }; + + let Some((backend, lease)) = map_once(local_ip, port) else { + warn(&format!( + "automatic port mapping unavailable (no UPnP/NAT-PMP router \ + found for port {port}); browsing/downloading firewalled peers \ + may fail unless you forward TCP {port} on your router" + )); + return; + }; + + manage(&backend, lease, port, stop); +} + +/// Renew `backend`'s mapping until `stop`, then remove it. Renews at half the +/// granted lease, floored at 30s so a pathologically short lease can't spin us +/// into a hot re-map loop. +fn manage(backend: &Backend, lease: u32, port: u16, stop: &AtomicBool) { + let renew_after = Duration::from_secs(u64::from((lease / 2).max(30))); + loop { + if sleep_until(renew_after, stop) { + break; // asked to stop + } + if !renew(backend, port) { + // Renewal failed; the lease will expire on its own. Stop trying. + warn("failed to renew the port mapping; it may expire"); + return; + } + } + remove(backend); +} + +/// Attempt UPnP then NAT-PMP once. Returns the backend and granted lease seconds. +fn map_once(local_ip: IpAddr, port: u16) -> Option<(Backend, u32)> { + if let Some(result) = map_upnp(local_ip, port) { + return Some(result); + } + map_natpmp(port) +} + +fn map_upnp(local_ip: IpAddr, port: u16) -> Option<(Backend, u32)> { + let options = SearchOptions { + bind_addr: SocketAddr::new(local_ip, 0), + timeout: Some(Duration::from_secs(5)), + ..SearchOptions::default() + }; + let gateway = search_gateway(options).ok()?; + let local_addr = SocketAddr::new(local_ip, port); + // We advertise our listen port verbatim to the Soulseek server, so the + // 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 + // be unreachable for our advertised address, so we give up rather than map + // a useless port. + if let Err(e) = gateway.add_port( + PortMappingProtocol::TCP, + port, + local_addr, + LEASE_SECS, + MAPPING_DESCRIPTION, + ) { + 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 + .get_external_ip() + .map_or_else(|_| "?".to_string(), |ip| ip.to_string()); + info(&format!( + "UPnP: mapped external {external}:{port} -> local {port}" + )); + Some(( + Backend::Upnp { + gateway: Box::new(gateway), + local_addr, + external_port: port, + }, + LEASE_SECS, + )) +} + +fn map_natpmp(port: u16) -> Option<(Backend, u32)> { + let gateway_ip = nat_pmp::default_gateway()?; + let gateway = + SocketAddr::new(IpAddr::V4(gateway_ip), nat_pmp::NAT_PMP_PORT); + let response = + nat_pmp::map_tcp(gateway, port, port, LEASE_SECS, NATPMP_ATTEMPTS) + .ok()?; + // We advertise our listen port verbatim, so a different external port would + // be unreachable for peers. Keep the mapping but warn loudly. + if response.external_port != port { + warn(&format!( + "NAT-PMP granted external port {} instead of {port}; peers may not \ + reach you — try forwarding TCP {port} manually", + response.external_port + )); + } + info(&format!( + "NAT-PMP: mapped external port {} -> local {port} (lease {}s)", + response.external_port, response.lifetime + )); + Some(( + Backend::NatPmp { + gateway, + internal_port: port, + }, + response.lifetime, + )) +} + +fn renew(backend: &Backend, port: u16) -> bool { + match backend { + Backend::Upnp { + gateway, + local_addr, + external_port, + } => gateway + .add_port( + PortMappingProtocol::TCP, + *external_port, + *local_addr, + LEASE_SECS, + MAPPING_DESCRIPTION, + ) + .is_ok(), + Backend::NatPmp { + gateway, + internal_port, + } => nat_pmp::map_tcp( + *gateway, + *internal_port, + port, + LEASE_SECS, + NATPMP_ATTEMPTS, + ) + .is_ok(), + } +} + +fn remove(backend: &Backend) { + match backend { + Backend::Upnp { + gateway, + external_port, + .. + } => { + let _ = + gateway.remove_port(PortMappingProtocol::TCP, *external_port); + } + Backend::NatPmp { + gateway, + internal_port, + } => { + let _ = nat_pmp::unmap_tcp(*gateway, *internal_port, 2); + } + } +} + +/// Sleep for `dur`, checking `stop` every 500ms. Returns `true` if `stop` was +/// signalled (so the caller should exit promptly). +fn sleep_until(dur: Duration, stop: &AtomicBool) -> bool { + let deadline = Instant::now() + dur; + while Instant::now() < deadline { + if stop.load(Ordering::SeqCst) { + return true; + } + thread::sleep(Duration::from_millis(500)); + } + stop.load(Ordering::SeqCst) +} + +/// The LAN IP of the interface that routes to the internet, found by asking the +/// OS which local address a UDP socket would use to reach a public address. No +/// packets are sent (UDP `connect` only sets the default route). +fn local_lan_ip() -> Option { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?; + socket.connect((Ipv4Addr::new(8, 8, 8, 8), 80)).ok()?; + let ip = socket.local_addr().ok()?.ip(); + // A loopback/unspecified address can't be a forward target. + if ip.is_loopback() || ip.is_unspecified() { + None + } else { + Some(ip) + } +} + +fn info(message: &str) { + logger::log(LogLevel::Info, &format!("[port-mapping] {message}")); +} + +fn warn(message: &str) { + logger::log(LogLevel::Warn, &format!("[port-mapping] {message}")); +} + +#[cfg(test)] +impl PortMapper { + /// Spawn a mapper that uses ONLY the NAT-PMP backend against an injected + /// gateway (no UPnP, no routing-table discovery), so the full spawn → map → + /// renew → cleanup-on-drop lifecycle can be driven against a mock gateway. + fn spawn_natpmp_test(port: u16, gateway: SocketAddr) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let thread_stop = stop.clone(); + let handle = thread::Builder::new() + .name("port-mapper-test".into()) + .spawn(move || { + if let Ok(response) = nat_pmp::map_tcp( + gateway, + port, + port, + LEASE_SECS, + NATPMP_ATTEMPTS, + ) { + let backend = Backend::NatPmp { + gateway, + internal_port: port, + }; + manage(&backend, response.lifetime, port, &thread_stop); + } + }) + .ok(); + Self { stop, handle } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use std::time::Duration as StdDuration; + + /// A mock NAT-PMP gateway that answers every map-TCP request with the same + /// granted values and forwards each raw request to the test. + fn mock_gateway() -> (SocketAddr, mpsc::Receiver>) { + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = socket.local_addr().unwrap(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut buf = [0u8; 64]; + // Answer many requests (initial map + cleanup unmap + any renews). + while let Ok((n, from)) = socket.recv_from(&mut buf) { + let request = buf[..n].to_vec(); + let internal = u16::from_be_bytes([request[4], request[5]]); + let lifetime = u32::from_be_bytes([ + request[8], + request[9], + request[10], + request[11], + ]); + // Response: version 0, opcode 130, result 0, echo internal port + // and grant the SAME external port + requested lifetime. + let mut reply = vec![0u8, 130, 0, 0]; + reply.extend_from_slice(&7u32.to_be_bytes()); // epoch + reply.extend_from_slice(&internal.to_be_bytes()); + reply.extend_from_slice(&internal.to_be_bytes()); + reply.extend_from_slice(&lifetime.to_be_bytes()); + if tx.send(request).is_err() { + break; + } + let _ = socket.send_to(&reply, from); + } + }); + (addr, rx) + } + + #[test] + fn service_maps_on_start_and_unmaps_on_drop() { + let (gateway, requests) = mock_gateway(); + let mapper = PortMapper::spawn_natpmp_test(2234, gateway); + + // The first request must be a map with our full requested lifetime. + let map_req = requests + .recv_timeout(StdDuration::from_secs(3)) + .expect("mapper should send an initial map request"); + assert_eq!(u16::from_be_bytes([map_req[4], map_req[5]]), 2234); + assert_eq!( + u32::from_be_bytes([ + map_req[8], + map_req[9], + map_req[10], + map_req[11] + ]), + LEASE_SECS, + "initial map should request the full lease" + ); + + // Dropping the mapper must remove the mapping (a lifetime-0 request). + drop(mapper); + let unmap_req = requests + .recv_timeout(StdDuration::from_secs(3)) + .expect("dropping the mapper should send an unmap request"); + assert_eq!( + u32::from_be_bytes([ + unmap_req[8], + unmap_req[9], + unmap_req[10], + unmap_req[11] + ]), + 0, + "cleanup should request lifetime 0" + ); + } +} diff --git a/soulseek-rs/src/port_mapping/nat_pmp.rs b/soulseek-rs/src/port_mapping/nat_pmp.rs new file mode 100644 index 0000000..d4c4698 --- /dev/null +++ b/soulseek-rs/src/port_mapping/nat_pmp.rs @@ -0,0 +1,316 @@ +//! Hand-rolled NAT-PMP (RFC 6886) over `std::net::UdpSocket`, no dependencies. +//! +//! The gateway address is always passed in as a `SocketAddr`, so tests point it +//! at an in-process mock gateway on `127.0.0.1`. Only [`default_gateway`] touches +//! the OS routing table. + +use std::io; +use std::net::{Ipv4Addr, SocketAddr, UdpSocket}; +use std::time::Duration; + +/// The well-known NAT-PMP gateway UDP port. +pub const NAT_PMP_PORT: u16 = 5351; + +const PROTO_VERSION: u8 = 0; +const OP_MAP_TCP: u8 = 2; +const RESPONSE_FLAG: u8 = 128; // response opcode == request opcode + 128 +/// RFC-recommended mapping lifetime (2 hours). +pub const RECOMMENDED_LIFETIME: u32 = 7200; + +#[derive(Debug)] +pub enum NatPmpError { + Io(io::Error), + ShortResponse, + BadVersion(u8), + BadOpcode(u8), + /// A non-zero RFC 6886 result code (1..=5). + ResultCode(u16), + /// Every retransmission timed out. + NoResponse, +} + +impl std::fmt::Display for NatPmpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "network error: {e}"), + Self::ShortResponse => write!(f, "truncated gateway response"), + Self::BadVersion(v) => write!(f, "unexpected version {v}"), + Self::BadOpcode(o) => write!(f, "unexpected opcode {o}"), + Self::ResultCode(c) => write!(f, "gateway result code {c}"), + Self::NoResponse => write!(f, "no response from gateway"), + } + } +} + +impl From for NatPmpError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +/// A successful TCP mapping. The gateway may grant a different external port or +/// a shorter lifetime than requested, so callers must use these values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MapResponse { + /// Seconds since the gateway's port table was initialised (reboot detector). + pub epoch: u32, + pub internal_port: u16, + pub external_port: u16, + pub lifetime: u32, +} + +/// Request (or, with `lifetime == 0`, remove) a TCP port mapping from the +/// NAT-PMP gateway at `gateway`. `attempts` bounds the retransmissions; RFC 6886 +/// allows up to 9 (~64s), but an interactive tool should pass a smaller number +/// so a non-NAT-PMP router doesn't stall for a minute. +/// +/// # Errors +/// Returns a [`NatPmpError`] on I/O failure, a malformed/short reply, a non-zero +/// gateway result code, or if no reply arrives within `attempts` retransmits. +pub fn map_tcp( + gateway: SocketAddr, + internal_port: u16, + suggested_external: u16, + lifetime: u32, + attempts: u32, +) -> Result { + let mut request = Vec::with_capacity(12); + request.push(PROTO_VERSION); + request.push(OP_MAP_TCP); + request.extend_from_slice(&[0, 0]); // reserved + request.extend_from_slice(&internal_port.to_be_bytes()); + request.extend_from_slice(&suggested_external.to_be_bytes()); + request.extend_from_slice(&lifetime.to_be_bytes()); + + let response = transact(gateway, &request, OP_MAP_TCP, attempts)?; + if response.len() < 16 { + return Err(NatPmpError::ShortResponse); + } + Ok(MapResponse { + epoch: u32::from_be_bytes([ + response[4], + response[5], + response[6], + response[7], + ]), + internal_port: u16::from_be_bytes([response[8], response[9]]), + external_port: u16::from_be_bytes([response[10], response[11]]), + lifetime: u32::from_be_bytes([ + response[12], + response[13], + response[14], + response[15], + ]), + }) +} + +/// Remove a TCP mapping for `internal_port` (external port 0, lifetime 0). +/// +/// # Errors +/// As [`map_tcp`]. +pub fn unmap_tcp( + gateway: SocketAddr, + internal_port: u16, + attempts: u32, +) -> Result<(), NatPmpError> { + map_tcp(gateway, internal_port, 0, 0, attempts).map(|_| ()) +} + +/// Send `request` to `gateway` and return the validated response body, retrying +/// with RFC 6886's 250ms exponential backoff up to `attempts` times. +fn transact( + gateway: SocketAddr, + request: &[u8], + expected_op: u8, + attempts: u32, +) -> Result, NatPmpError> { + let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?; + socket.connect(gateway)?; + + let mut timeout = Duration::from_millis(250); + let mut buf = [0u8; 32]; + for _ in 0..attempts.max(1) { + socket.send(request)?; + socket.set_read_timeout(Some(timeout))?; + match socket.recv(&mut buf) { + Ok(n) => { + let response = &buf[..n]; + if response.len() < 4 { + return Err(NatPmpError::ShortResponse); + } + if response[0] != PROTO_VERSION { + return Err(NatPmpError::BadVersion(response[0])); + } + if response[1] != expected_op + RESPONSE_FLAG { + return Err(NatPmpError::BadOpcode(response[1])); + } + let result = u16::from_be_bytes([response[2], response[3]]); + if result != 0 { + return Err(NatPmpError::ResultCode(result)); + } + return Ok(response.to_vec()); + } + Err(e) + if matches!( + e.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + timeout = timeout.saturating_mul(2); + } + Err(e) => return Err(NatPmpError::Io(e)), + } + } + Err(NatPmpError::NoResponse) +} + +/// Best-effort discovery of the IPv4 default-gateway address from the OS routing +/// table. Supports macOS and Linux; returns `None` elsewhere or on any parse +/// failure (callers should treat NAT-PMP as unavailable then). +#[must_use] +pub fn default_gateway() -> Option { + #[cfg(target_os = "macos")] + { + let out = std::process::Command::new("route") + .args(["-n", "get", "default"]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + for line in text.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("gateway:") { + return rest.trim().parse().ok(); + } + } + None + } + #[cfg(target_os = "linux")] + { + // /proc/net/route: tab-separated; the default route has Destination + // 00000000, and Gateway is a little-endian hex u32. + let text = std::fs::read_to_string("/proc/net/route").ok()?; + for line in text.lines().skip(1) { + let mut fields = line.split_whitespace(); + let _iface = fields.next()?; + let destination = fields.next()?; + let gateway = fields.next()?; + if destination == "00000000" { + let raw = u32::from_str_radix(gateway, 16).ok()?; + return Some(Ipv4Addr::from(raw.to_le_bytes())); + } + } + None + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use std::thread; + + /// A minimal in-process NAT-PMP gateway for tests. It receives one request, + /// hands the raw bytes back to the test, and replies with `reply`. + fn mock_gateway(reply: Vec) -> (SocketAddr, mpsc::Receiver>) { + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = socket.local_addr().unwrap(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut buf = [0u8; 64]; + if let Ok((n, from)) = socket.recv_from(&mut buf) { + let _ = tx.send(buf[..n].to_vec()); + let _ = socket.send_to(&reply, from); + } + }); + (addr, rx) + } + + fn map_reply( + result: u16, + internal: u16, + external: u16, + lifetime: u32, + ) -> Vec { + let mut r = vec![PROTO_VERSION, OP_MAP_TCP + RESPONSE_FLAG]; + r.extend_from_slice(&result.to_be_bytes()); + r.extend_from_slice(&123u32.to_be_bytes()); // epoch + r.extend_from_slice(&internal.to_be_bytes()); + r.extend_from_slice(&external.to_be_bytes()); + r.extend_from_slice(&lifetime.to_be_bytes()); + r + } + + #[test] + fn map_tcp_sends_correct_request_and_parses_response() { + // Gateway grants a DIFFERENT external port and a SHORTER lease than asked. + let (gateway, requests) = mock_gateway(map_reply(0, 2234, 40000, 3600)); + let resp = map_tcp(gateway, 2234, 2234, 7200, 3) + .expect("mapping should succeed"); + + // The request bytes must match the RFC 6886 map-TCP layout. + let req = requests.recv().unwrap(); + assert_eq!(req.len(), 12); + assert_eq!(req[0], 0, "version"); + assert_eq!(req[1], 2, "opcode TCP"); + assert_eq!(&req[2..4], &[0, 0], "reserved"); + assert_eq!(u16::from_be_bytes([req[4], req[5]]), 2234, "internal port"); + assert_eq!( + u16::from_be_bytes([req[6], req[7]]), + 2234, + "suggested external" + ); + assert_eq!( + u32::from_be_bytes([req[8], req[9], req[10], req[11]]), + 7200, + "lifetime" + ); + + // The response's granted values (not the requested ones) are returned. + assert_eq!(resp.external_port, 40000); + assert_eq!(resp.lifetime, 3600); + assert_eq!(resp.internal_port, 2234); + } + + #[test] + fn map_tcp_surfaces_gateway_result_code() { + // Result code 2 = "not authorized/refused" per RFC 6886. + let (gateway, _req) = mock_gateway(map_reply(2, 2234, 0, 0)); + match map_tcp(gateway, 2234, 2234, 7200, 3) { + Err(NatPmpError::ResultCode(2)) => {} + other => panic!("expected ResultCode(2), got {other:?}"), + } + } + + #[test] + fn map_tcp_times_out_when_gateway_is_silent() { + // Bind a socket but never reply: the mapper must give up, not hang. + let dead = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let addr = dead.local_addr().unwrap(); + drop(dead); // nothing listening now + match map_tcp(addr, 2234, 2234, 7200, 2) { + Err(NatPmpError::NoResponse | NatPmpError::Io(_)) => {} + other => panic!("expected timeout/io error, got {other:?}"), + } + } + + #[test] + fn unmap_sends_zero_lifetime_and_port() { + let (gateway, requests) = mock_gateway(map_reply(0, 2234, 0, 0)); + unmap_tcp(gateway, 2234, 3).expect("unmap should succeed"); + let req = requests.recv().unwrap(); + assert_eq!( + u16::from_be_bytes([req[6], req[7]]), + 0, + "external port 0 to delete" + ); + assert_eq!( + u32::from_be_bytes([req[8], req[9], req[10], req[11]]), + 0, + "lifetime 0 to delete" + ); + } +}