feat(net): auto-map the listen port via UPnP-IGD and NAT-PMP
Behind a home router the client is unreachable for incoming peer connections, so browsing/downloading firewalled peers (and the server-brokered connect-back) times out. Add a best-effort port mapper to the binary crate that, when listening is enabled, makes us reachable: - NAT-PMP (RFC 6886) hand-rolled on std::net::UdpSocket, zero deps, with an injectable gateway address so it is fully testable. - UPnP-IGD via the blocking igd-next crate as the primary path. - A background PortMapper that discovers off the main thread (never blocks startup), renews at half the lease, and removes the mapping on drop. Any failure is non-fatal and logged. - Maps the EXACT listen port only (we advertise it verbatim via SetWaitPort, so a different external port would be unreachable) and warns the user to forward manually otherwise. Wired into the TUI, search, and CLI browse (the P2P modes); chat/rooms/ messaging are server-mediated and skip it. e2e-tested against an in-process mock NAT-PMP gateway: the map request bytes, response parsing, result-code/timeout handling, and the full spawn -> map -> drop -> unmap service lifecycle.
This commit is contained in:
Generated
+370
-3
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<AtomicBool>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<igd_next::Gateway>,
|
||||
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<IpAddr> {
|
||||
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<Vec<u8>>) {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<io::Error> 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<MapResponse, NatPmpError> {
|
||||
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<Vec<u8>, 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<Ipv4Addr> {
|
||||
#[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<u8>) -> (SocketAddr, mpsc::Receiver<Vec<u8>>) {
|
||||
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<u8> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user