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()
+382
View File
@@ -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"
);
}
}
+316
View File
@@ -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"
);
}
}