use parking_lot::Mutex;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
use async_trait::async_trait;
use igd_next::aio::tokio::Tokio;
use igd_next::aio::Gateway;
use igd_next::{PortMappingProtocol, SearchOptions};
use super::{PortMapperClient, PortMapping, PortMappingError, Protocol};
pub const UPNP_DEADLINE: Duration = Duration::from_secs(2);
pub const UPNP_SEARCH_TIMEOUT: Duration = Duration::from_millis(1500);
pub const UPNP_DESCRIPTION: &str = "cyberdeck-mesh";
pub struct UpnpMapper {
local_ip: IpAddr,
gateway: Mutex<Option<Gateway<Tokio>>>,
}
impl UpnpMapper {
pub fn new(local_ip: IpAddr) -> Self {
Self {
local_ip,
gateway: Mutex::new(None),
}
}
fn cached_gateway(&self) -> Option<Gateway<Tokio>> {
self.gateway.lock().clone()
}
fn cache_gateway(&self, gw: Gateway<Tokio>) {
*self.gateway.lock() = Some(gw);
}
fn invalidate_gateway(&self) {
*self.gateway.lock() = None;
}
async fn gateway(&self) -> Result<Gateway<Tokio>, PortMappingError> {
if let Some(gw) = self.cached_gateway() {
return Ok(gw);
}
let opts = SearchOptions {
timeout: Some(UPNP_SEARCH_TIMEOUT),
..Default::default()
};
let gw = igd_next::aio::tokio::search_gateway(opts)
.await
.map_err(search_err_to_port_mapping)?;
self.cache_gateway(gw.clone());
Ok(gw)
}
}
#[async_trait]
impl PortMapperClient for UpnpMapper {
async fn probe(&self) -> Result<(), PortMappingError> {
match tokio::time::timeout(UPNP_DEADLINE, async {
let gw = self.gateway().await?;
gw.get_external_ip()
.await
.map_err(|_| PortMappingError::Transport("get_external_ip failed".into()))
})
.await
{
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => {
self.invalidate_gateway();
Err(e)
}
Err(_) => {
self.invalidate_gateway();
Err(PortMappingError::Timeout)
}
}
}
async fn install(
&self,
internal_port: u16,
ttl: Duration,
) -> Result<PortMapping, PortMappingError> {
let lease = ttl.as_secs().min(u32::MAX as u64) as u32;
let result = tokio::time::timeout(UPNP_DEADLINE, async {
let gw = self.gateway().await?;
let local = SocketAddr::new(self.local_ip, internal_port);
let external_ip = gw
.get_external_ip()
.await
.map_err(|_| PortMappingError::Transport("get_external_ip failed".into()))?;
let actual_external_port = gw
.add_any_port(PortMappingProtocol::UDP, local, lease, UPNP_DESCRIPTION)
.await
.map_err(add_any_port_err_to_port_mapping)?;
Ok::<_, PortMappingError>(PortMapping {
external: SocketAddr::new(external_ip, actual_external_port),
internal_port,
ttl: Duration::from_secs(lease as u64),
protocol: Protocol::Upnp,
})
})
.await;
match result {
Ok(Ok(mapping)) => Ok(mapping),
Ok(Err(e)) => {
self.invalidate_gateway();
Err(e)
}
Err(_) => {
self.invalidate_gateway();
Err(PortMappingError::Timeout)
}
}
}
async fn renew(&self, mapping: &PortMapping) -> Result<PortMapping, PortMappingError> {
self.install(mapping.internal_port, mapping.ttl).await
}
async fn remove(&self, mapping: &PortMapping) {
let _ = tokio::time::timeout(UPNP_DEADLINE, async {
let gw = self.gateway().await?;
gw.remove_port(PortMappingProtocol::UDP, mapping.external.port())
.await
.map_err(|_| PortMappingError::Transport("remove_port failed".into()))?;
Ok::<_, PortMappingError>(())
})
.await;
}
}
fn search_err_to_port_mapping(err: igd_next::SearchError) -> PortMappingError {
use igd_next::SearchError;
match err {
SearchError::NoResponseWithinTimeout => PortMappingError::Unavailable,
SearchError::InvalidResponse => PortMappingError::Transport("invalid IGD response".into()),
SearchError::XmlError(e) => PortMappingError::Transport(format!("IGD XML parse: {e}")),
SearchError::Utf8Error(e) => PortMappingError::Transport(format!("IGD UTF-8: {e}")),
SearchError::IoError(e) => PortMappingError::Transport(format!("IGD I/O: {e}")),
other => PortMappingError::Transport(other.to_string()),
}
}
#[allow(dead_code)]
fn add_port_err_to_port_mapping(err: igd_next::AddPortError) -> PortMappingError {
use igd_next::AddPortError;
match err {
AddPortError::PortInUse => PortMappingError::Refused("port-in-use".into()),
AddPortError::SamePortValuesRequired => {
PortMappingError::Refused("same-port-required".into())
}
AddPortError::OnlyPermanentLeasesSupported => {
PortMappingError::Refused("only-permanent-leases-supported".into())
}
AddPortError::DescriptionTooLong => {
PortMappingError::Transport("description too long".into())
}
AddPortError::ExternalPortZeroInvalid | AddPortError::InternalPortZeroInvalid => {
PortMappingError::Transport("zero port invalid".into())
}
AddPortError::RequestError(e) => PortMappingError::Transport(format!("IGD request: {e}")),
AddPortError::ActionNotAuthorized => {
PortMappingError::Refused("action-not-authorized".into())
}
}
}
fn add_any_port_err_to_port_mapping(err: igd_next::AddAnyPortError) -> PortMappingError {
use igd_next::AddAnyPortError;
match err {
AddAnyPortError::ExternalPortInUse => {
PortMappingError::Refused("external-port-in-use".into())
}
AddAnyPortError::NoPortsAvailable => PortMappingError::Refused("no-ports-available".into()),
AddAnyPortError::OnlyPermanentLeasesSupported => {
PortMappingError::Refused("only-permanent-leases-supported".into())
}
AddAnyPortError::DescriptionTooLong => {
PortMappingError::Transport("description too long".into())
}
AddAnyPortError::InternalPortZeroInvalid => {
PortMappingError::Transport("zero port invalid".into())
}
AddAnyPortError::RequestError(e) => {
PortMappingError::Transport(format!("IGD request: {e}"))
}
AddAnyPortError::ActionNotAuthorized => {
PortMappingError::Refused("action-not-authorized".into())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use igd_next::AddPortError;
#[test]
fn error_mapping_no_response_is_unavailable() {
let mapped = search_err_to_port_mapping(igd_next::SearchError::NoResponseWithinTimeout);
assert!(matches!(mapped, PortMappingError::Unavailable));
}
#[test]
fn error_mapping_port_in_use_is_refused() {
let mapped = add_port_err_to_port_mapping(AddPortError::PortInUse);
match mapped {
PortMappingError::Refused(msg) => assert_eq!(msg, "port-in-use"),
other => panic!("expected Refused(port-in-use), got {other:?}"),
}
}
#[test]
fn error_mapping_action_not_authorized_is_refused() {
let mapped = add_port_err_to_port_mapping(AddPortError::ActionNotAuthorized);
match mapped {
PortMappingError::Refused(msg) => assert_eq!(msg, "action-not-authorized"),
other => panic!("expected Refused(action-not-authorized), got {other:?}"),
}
}
#[test]
fn error_mapping_zero_port_is_transport() {
let mapped = add_port_err_to_port_mapping(AddPortError::ExternalPortZeroInvalid);
assert!(matches!(mapped, PortMappingError::Transport(_)));
}
#[test]
fn constructor_caches_no_gateway_initially() {
let mapper = UpnpMapper::new("10.0.0.1".parse().unwrap());
assert!(mapper.cached_gateway().is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn probe_on_no_router_returns_unavailable() {
let mapper = UpnpMapper::new("127.0.0.1".parse().unwrap());
let start = tokio::time::Instant::now();
let res = mapper.probe().await;
let elapsed = start.elapsed();
assert!(res.is_err(), "probe should fail in a no-IGD env");
assert!(
elapsed < Duration::from_secs(3),
"probe should respect deadline; took {elapsed:?}",
);
}
}