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:
michel
2026-07-19 21:10:15 +02:00
parent e59c2bc199
commit 5ba2f9e181
5 changed files with 1083 additions and 3 deletions
+14
View File
@@ -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()