test(net): e2e-test the UPnP path + add a portmap diagnostic

Address the gap that the port mapper's UPnP path and gateway discovery
had no automated coverage (only NAT-PMP did):

- UPnP e2e: split map_via_gateway out of discovery and drive it through
  igd-next against an in-process mock IGD (a std HTTP server returning a
  canned SOAP envelope). Verifies the real add_port SOAP round-trip and
  that we request the exact external/internal port + LAN client.
- default_gateway parsing is now pure, testable functions with unit
  tests for the macOS and Linux formats (including the 0.0.0.0 skip).
- New 'portmap' CLI subcommand (needs no login): synchronously probes
  UPnP/NAT-PMP against the real router and reports whether the port was
  opened + the external address, then removes the test mapping — so a
  user can verify reachability on their own network.

9 port_mapping tests now cover the NAT-PMP protocol, the UPnP SOAP
round-trip, gateway parsing, and the map/renew/cleanup lifecycle.
This commit is contained in:
michel
2026-07-19 21:46:46 +02:00
parent 84c571767a
commit 2d5fc26db7
5 changed files with 271 additions and 26 deletions
+13
View File
@@ -41,6 +41,17 @@ fn main() -> Result<()> {
};
}
// `portmap` is a local network diagnostic; it needs no server credentials,
// so handle it before requiring a username/password.
if matches!(cli.command, Some(Commands::Portmap)) {
println!(
"Testing automatic port mapping for TCP {}…",
cli.listener_port
);
println!("{}", port_mapping::diagnose(cli.listener_port));
return Ok(());
}
let username = cli.username.ok_or_else(|| {
color_eyre::eyre::eyre!(
"Username required: use --username or set SOULSEEK_USERNAME env var"
@@ -141,6 +152,8 @@ fn main() -> Result<()> {
message.as_deref(),
listen_secs,
),
// Handled before the credential check above.
Some(Commands::Portmap) => unreachable!(),
None => {
use ratatui::crossterm::{
event::EnableMouseCapture,