Przeglądaj źródła

net: add UPnP IGD NAT traversal support

Implement opt-in UPnP IGD port forwarding for P2P nodes behind NAT,
enabling inbound connections without manual router configuration.
This is gated behind the `upnp-igd` feature flag.

Changes:
- Add PortMapping trait for pluggable port-mapping protocols (UPnP, PCP, NAT-PMP...)
- Add optional oxy-upnp-igd v0.1 dependency behind `upnp-igd`
- Parse UpnpConfig (enabled, discovery_timeout, lease_duration, description,
  refresh_interval) from listener URL
- Periodically refresh external address and publish it to the P2P network
- Add UPnP-specific error handling
- Enable `upnp-igd` feature in darkirc (opt-in; not enabled by default)

Port mappings are created lazily once a gateway is discovered and are
automatically renewed. External addresses are tagged `source=upnp` and
refreshed at a configurable interval.

Signed-off-by: Gioacchino Mazzurco <gio@polymathes.cc>
Reviewed-by: grug
Reviewed-by: upgrayedd
G10h4ck 6 miesięcy temu
rodzic
commit
7e2c613054

+ 38 - 0
Cargo.lock

@@ -1847,6 +1847,7 @@ dependencies = [
  "monero",
  "monero",
  "nu-ansi-term",
  "nu-ansi-term",
  "num-bigint",
  "num-bigint",
+ "oxy-upnp-igd",
  "parking_lot 0.12.5",
  "parking_lot 0.12.5",
  "pin-project-lite",
  "pin-project-lite",
  "plotters",
  "plotters",
@@ -3352,6 +3353,17 @@ dependencies = [
  "url",
  "url",
 ]
 ]
 
 
+[[package]]
+name = "getifaddrs"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06f770241660efeeedb30b48ccbe0a8e6f649322148237539e71f3ccd14a4461"
+dependencies = [
+ "bitflags 2.10.0",
+ "libc",
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 [[package]]
 name = "getrandom"
 name = "getrandom"
 version = "0.2.17"
 version = "0.2.17"
@@ -4862,6 +4874,23 @@ dependencies = [
  "memchr",
  "memchr",
 ]
 ]
 
 
+[[package]]
+name = "oxy-upnp-igd"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27818d61515cd7f5592635320b7fe8981ff2c0a133e5ba94bb4707b59627f0d"
+dependencies = [
+ "async-channel 2.5.0",
+ "async-trait",
+ "futures",
+ "getifaddrs",
+ "quick-xml",
+ "smol",
+ "thiserror 2.0.17",
+ "tracing",
+ "url",
+]
+
 [[package]]
 [[package]]
 name = "p256"
 name = "p256"
 version = "0.13.2"
 version = "0.13.2"
@@ -5566,6 +5595,15 @@ version = "1.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
 checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
 
 
+[[package]]
+name = "quick-xml"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2e3bf4aa9d243beeb01a7b3bc30b77cfe2c44e24ec02d751a7104a53c2c49a1"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 [[package]]
 name = "quinn-proto-smol"
 name = "quinn-proto-smol"
 version = "0.12.0"
 version = "0.12.0"

+ 7 - 0
Cargo.toml

@@ -75,6 +75,9 @@ ed25519-compact = {version = "2.2.0", optional = true}
 rcgen = {version = "0.14.6", optional = true, features = ["pem"]}
 rcgen = {version = "0.14.6", optional = true, features = ["pem"]}
 x509-parser = {version = "0.18.0", features = ["validate", "verify"], optional = true}
 x509-parser = {version = "0.18.0", features = ["validate", "verify"], optional = true}
 
 
+# UPnP IGD for NAT traversal
+oxy-upnp-igd = {version = "0.1", optional = true}
+
 # Encoding
 # Encoding
 bs58 = {version = "0.5.1", optional = true}
 bs58 = {version = "0.5.1", optional = true}
 hex = {version = "0.4.3", optional = true}
 hex = {version = "0.4.3", optional = true}
@@ -205,6 +208,10 @@ p2p-tor = [
     "tor-cell",
     "tor-cell",
 ]
 ]
 
 
+upnp-igd = [
+    "oxy-upnp-igd",
+]
+
 net-defaults = [
 net-defaults = [
     "async-trait",
     "async-trait",
     "ed25519-compact",
     "ed25519-compact",

+ 4 - 0
bin/darkirc/Cargo.toml

@@ -17,6 +17,10 @@ path = "src/lib.rs"
 name = "darkirc"
 name = "darkirc"
 path = "src/main.rs"
 path = "src/main.rs"
 
 
+[features]
+default = []
+upnp-igd = ["darkfi/upnp-igd"]
+
 [dependencies]
 [dependencies]
 darkfi = {path = "../../", features = ["async-daemonize", "event-graph", "rpc", "zk"]}
 darkfi = {path = "../../", features = ["async-daemonize", "event-graph", "rpc", "zk"]}
 darkfi-sdk = {path = "../../src/sdk", features = ["async"]}
 darkfi-sdk = {path = "../../src/sdk", features = ["async"]}

+ 4 - 0
src/error.rs

@@ -202,6 +202,10 @@ pub enum Error {
     #[error("Invalid state transition: current_state={0}, end_state={1}")]
     #[error("Invalid state transition: current_state={0}, end_state={1}")]
     HostStateBlocked(String, String),
     HostStateBlocked(String, String),
 
 
+    #[cfg(feature = "upnp-igd")]
+    #[error(transparent)]
+    UpnpError(#[from] oxy_upnp_igd::Error),
+
     // =============
     // =============
     // Crypto errors
     // Crypto errors
     // =============
     // =============

+ 35 - 5
src/net/acceptor.rs

@@ -24,18 +24,27 @@ use std::{
     },
     },
 };
 };
 
 
-use smol::Executor;
 use tracing::warn;
 use tracing::warn;
 use url::Url;
 use url::Url;
 
 
+#[cfg(feature = "upnp-igd")]
+use smol::lock::Mutex as AsyncMutex;
+
 use super::{
 use super::{
     channel::{Channel, ChannelPtr},
     channel::{Channel, ChannelPtr},
     hosts::HostColor,
     hosts::HostColor,
     session::SessionWeakPtr,
     session::SessionWeakPtr,
     transport::{Listener, PtListener},
     transport::{Listener, PtListener},
 };
 };
+
+#[cfg(feature = "upnp-igd")]
+use super::upnp::{setup_port_mappings, PortMapping};
+
 use crate::{
 use crate::{
-    system::{CondVar, Publisher, PublisherPtr, StoppableTask, StoppableTaskPtr, Subscription},
+    system::{
+        CondVar, ExecutorPtr, Publisher, PublisherPtr, StoppableTask, StoppableTaskPtr,
+        Subscription,
+    },
     util::logger::verbose,
     util::logger::verbose,
     Error, Result,
     Error, Result,
 };
 };
@@ -49,6 +58,8 @@ pub struct Acceptor {
     task: StoppableTaskPtr,
     task: StoppableTaskPtr,
     session: SessionWeakPtr,
     session: SessionWeakPtr,
     conn_count: AtomicUsize,
     conn_count: AtomicUsize,
+    #[cfg(feature = "upnp-igd")]
+    port_mappings: AsyncMutex<Vec<Arc<dyn PortMapping>>>,
 }
 }
 
 
 impl Acceptor {
 impl Acceptor {
@@ -59,11 +70,13 @@ impl Acceptor {
             task: StoppableTask::new(),
             task: StoppableTask::new(),
             session,
             session,
             conn_count: AtomicUsize::new(0),
             conn_count: AtomicUsize::new(0),
+            #[cfg(feature = "upnp-igd")]
+            port_mappings: AsyncMutex::new(Vec::new()),
         })
         })
     }
     }
 
 
     /// Start accepting inbound socket connections
     /// Start accepting inbound socket connections
-    pub async fn start(self: Arc<Self>, endpoint: Url, ex: Arc<Executor<'_>>) -> Result<()> {
+    pub async fn start(self: Arc<Self>, endpoint: Url, ex: ExecutorPtr) -> Result<()> {
         let datastore =
         let datastore =
             self.session.upgrade().unwrap().p2p().settings().read().await.p2p_datastore.clone();
             self.session.upgrade().unwrap().p2p().settings().read().await.p2p_datastore.clone();
 
 
@@ -88,12 +101,29 @@ impl Acceptor {
                 .push(onion_addr);
                 .push(onion_addr);
         }
         }
 
 
+        #[cfg(feature = "upnp-igd")]
+        {
+            let actual_endpoint = listener.endpoint().await;
+            let settings = self.session.upgrade().unwrap().p2p().settings();
+            let mappings = setup_port_mappings(&actual_endpoint, settings, ex.clone());
+            self.port_mappings.lock().await.extend(mappings);
+        }
+
         self.accept(ptlistener, ex);
         self.accept(ptlistener, ex);
         Ok(())
         Ok(())
     }
     }
 
 
     /// Stop accepting inbound socket connections
     /// Stop accepting inbound socket connections
     pub async fn stop(&self) {
     pub async fn stop(&self) {
+        // Stop all port mappings
+        #[cfg(feature = "upnp-igd")]
+        {
+            let mappings = std::mem::take(&mut *self.port_mappings.lock().await);
+            for mapping in mappings {
+                mapping.stop();
+            }
+        }
+
         // Send stop signal
         // Send stop signal
         self.task.stop().await;
         self.task.stop().await;
     }
     }
@@ -104,7 +134,7 @@ impl Acceptor {
     }
     }
 
 
     /// Run the accept loop in a new thread and error if a connection problem occurs
     /// Run the accept loop in a new thread and error if a connection problem occurs
-    fn accept(self: Arc<Self>, listener: Box<dyn PtListener>, ex: Arc<Executor<'_>>) {
+    fn accept(self: Arc<Self>, listener: Box<dyn PtListener>, ex: ExecutorPtr) {
         let self_ = self.clone();
         let self_ = self.clone();
         self.task.clone().start(
         self.task.clone().start(
             self.run_accept_loop(listener, ex.clone()),
             self.run_accept_loop(listener, ex.clone()),
@@ -118,7 +148,7 @@ impl Acceptor {
     async fn run_accept_loop(
     async fn run_accept_loop(
         self: Arc<Self>,
         self: Arc<Self>,
         listener: Box<dyn PtListener>,
         listener: Box<dyn PtListener>,
-        ex: Arc<Executor<'_>>,
+        ex: ExecutorPtr,
     ) -> Result<()> {
     ) -> Result<()> {
         // CondVar used to notify the loop to recheck if new connections can
         // CondVar used to notify the loop to recheck if new connections can
         // be accepted by the listener.
         // be accepted by the listener.

+ 4 - 0
src/net/mod.rs

@@ -55,6 +55,10 @@ pub use message_publisher::MessageSubscription;
 /// Exposes agnostic dialers and agnostic listeners.
 /// Exposes agnostic dialers and agnostic listeners.
 pub mod transport;
 pub mod transport;
 
 
+/// Port mapping protocols (UPnP, NAT-PMP, PCP)
+#[cfg(feature = "upnp-igd")]
+pub mod upnp;
+
 /// Hosts are a list of network addresses used when establishing outbound
 /// Hosts are a list of network addresses used when establishing outbound
 /// connections.
 /// connections.
 ///
 ///

+ 13 - 1
src/net/protocol/protocol_address.rs

@@ -20,6 +20,7 @@ use async_trait::async_trait;
 use smol::{lock::RwLock as AsyncRwLock, Executor};
 use smol::{lock::RwLock as AsyncRwLock, Executor};
 use std::{sync::Arc, time::UNIX_EPOCH};
 use std::{sync::Arc, time::UNIX_EPOCH};
 use tracing::debug;
 use tracing::debug;
+use url::Url;
 
 
 use super::{
 use super::{
     super::{
     super::{
@@ -76,6 +77,16 @@ const PROTO_NAME: &str = "ProtocolAddress";
 const TRANSPORT_COMBOS: [&str; 9] =
 const TRANSPORT_COMBOS: [&str; 9] =
     ["tor", "tls", "tcp", "nym", "i2p", "tor+tls", "nym+tls", "tcp+tls", "i2p+tls"];
     ["tor", "tls", "tcp", "nym", "i2p", "tor+tls", "nym+tls", "tcp+tls", "i2p+tls"];
 
 
+/// Strip query parameters from a URL before broadcasting.
+///
+/// This prevents leaking internal tracking identifiers (e.g., UPnP cookies)
+/// that could be used for fingerprinting nodes on the P2P network.
+fn strip_query_params(url: &Url) -> Url {
+    let mut stripped = url.clone();
+    stripped.set_query(None);
+    stripped
+}
+
 impl ProtocolAddress {
 impl ProtocolAddress {
     /// Creates a new address protocol. Makes an address, an external address
     /// Creates a new address protocol. Makes an address, an external address
     /// and a get-address subscription and adds them to the address protocol
     /// and a get-address subscription and adds them to the address protocol
@@ -244,8 +255,9 @@ impl ProtocolAddress {
         let mut addrs = vec![];
         let mut addrs = vec![];
 
 
         for addr in external_addrs {
         for addr in external_addrs {
+            let stripped_addr = strip_query_params(&addr);
             let last_seen = UNIX_EPOCH.elapsed().unwrap().as_secs();
             let last_seen = UNIX_EPOCH.elapsed().unwrap().as_secs();
-            addrs.push((addr, last_seen));
+            addrs.push((stripped_addr, last_seen));
         }
         }
 
 
         debug!(
         debug!(

+ 5 - 10
src/net/session/inbound_session.rs

@@ -26,7 +26,7 @@
 use std::sync::{Arc, Weak};
 use std::sync::{Arc, Weak};
 
 
 use async_trait::async_trait;
 use async_trait::async_trait;
-use smol::{lock::Mutex, Executor};
+use smol::lock::Mutex;
 use tracing::{debug, error, warn};
 use tracing::{debug, error, warn};
 use url::Url;
 use url::Url;
 
 
@@ -40,7 +40,7 @@ use super::{
     Session, SessionBitFlag, SESSION_INBOUND,
     Session, SessionBitFlag, SESSION_INBOUND,
 };
 };
 use crate::{
 use crate::{
-    system::{StoppableTask, StoppableTaskPtr, Subscription},
+    system::{ExecutorPtr, StoppableTask, StoppableTaskPtr, Subscription},
     util::logger::verbose,
     util::logger::verbose,
     Error, Result,
     Error, Result,
 };
 };
@@ -136,7 +136,7 @@ impl InboundSession {
         index: usize,
         index: usize,
         accept_addr: Url,
         accept_addr: Url,
         acceptor: AcceptorPtr,
         acceptor: AcceptorPtr,
-        ex: Arc<Executor<'_>>,
+        ex: ExecutorPtr,
     ) -> Result<()> {
     ) -> Result<()> {
         verbose!(target: "net::inbound_session", "[P2P] Starting Inbound session #{index} on {accept_addr}");
         verbose!(target: "net::inbound_session", "[P2P] Starting Inbound session #{index} on {accept_addr}");
         // Start listener
         // Start listener
@@ -156,7 +156,7 @@ impl InboundSession {
         self: Arc<Self>,
         self: Arc<Self>,
         channel_sub: Subscription<Result<ChannelPtr>>,
         channel_sub: Subscription<Result<ChannelPtr>>,
         index: usize,
         index: usize,
-        ex: Arc<Executor<'_>>,
+        ex: ExecutorPtr,
     ) -> Result<()> {
     ) -> Result<()> {
         loop {
         loop {
             let channel = channel_sub.receive().await?;
             let channel = channel_sub.receive().await?;
@@ -169,12 +169,7 @@ impl InboundSession {
 
 
     /// Registers the channel. First performs a network handshake and starts the channel.
     /// Registers the channel. First performs a network handshake and starts the channel.
     /// Then starts sending keep-alive and address messages across the channel.
     /// Then starts sending keep-alive and address messages across the channel.
-    async fn setup_channel(
-        self: Arc<Self>,
-        index: usize,
-        channel: ChannelPtr,
-        ex: Arc<Executor<'_>>,
-    ) {
+    async fn setup_channel(self: Arc<Self>, index: usize, channel: ChannelPtr, ex: ExecutorPtr) {
         verbose!(
         verbose!(
              target: "net::inbound_session::setup_channel",
              target: "net::inbound_session::setup_channel",
              "[P2P] Connected Inbound #{index} [{}]", channel.display_address()
              "[P2P] Connected Inbound #{index} [{}]", channel.display_address()

+ 419 - 0
src/net/upnp.rs

@@ -0,0 +1,419 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//! UPnP IGD port mapping implementation
+//!
+//! This module provides UPnP Internet Gateway Device (IGD) port mapping
+//! with automatic lease renewal and persistent retry for roaming support.
+
+use std::{
+    collections::hash_map::DefaultHasher,
+    hash::{Hash, Hasher},
+    sync::Arc,
+    time::Duration,
+};
+
+use async_trait::async_trait;
+use oxy_upnp_igd::{add_port_mapping_lazy, Protocol, RenewalHandle};
+use smol::lock::{Mutex as AsyncMutex, RwLock as AsyncRwLock};
+use tracing::error;
+use url::Url;
+
+use crate::{
+    net::settings::Settings,
+    system::{sleep, ExecutorPtr, StoppableTask, StoppableTaskPtr},
+    util::logger::verbose,
+    Error, Result,
+};
+
+/// Trait for port mapping protocols (UPnP, NAT-PMP, PCP)
+///
+/// Each protocol runs its own persistent task that:
+/// 1. Attempts to discover a gateway
+/// 2. Creates port mappings when gateway is found
+/// 3. Periodically refreshes the external address
+/// 4. Retries discovery on failures (supports roaming)
+pub trait PortMapping: Send + Sync {
+    /// Start the port mapping protocol - runs forever with retries
+    fn start(
+        self: Arc<Self>,
+        settings: Arc<AsyncRwLock<Settings>>,
+        executor: ExecutorPtr,
+    ) -> Result<()>;
+
+    /// Stop the port mapping protocol
+    fn stop(self: Arc<Self>);
+}
+
+/// UPnP port mapping configuration
+#[derive(Clone, Debug)]
+pub struct UpnpConfig {
+    /// Port mapping lease duration in seconds
+    pub lease_duration: u32,
+    /// Gateway discovery timeout in seconds
+    pub discovery_timeout_secs: u64,
+    /// Description for port mapping (visible in router admin panel)
+    pub mapping_description: String,
+    /// External address refresh interval in seconds
+    pub ext_addr_refresh: u64,
+    /// How often to retry discovery if gateway not found (roaming support)
+    pub retry_interval_secs: u64,
+}
+
+impl Default for UpnpConfig {
+    fn default() -> Self {
+        Self {
+            lease_duration: 300,
+            discovery_timeout_secs: 3,
+            mapping_description: "DarkFi".to_string(),
+            ext_addr_refresh: 120,
+            retry_interval_secs: 60,
+        }
+    }
+}
+
+/// UPnP IGD port mapping protocol
+///
+/// Maintains a persistent task that:
+/// - Discovers UPnP gateway
+/// - Creates port mappings
+/// - Periodically refreshes the external address
+/// - Retries discovery on failures (supports roaming devices)
+pub struct UpnpPortMapping {
+    config: UpnpConfig,
+    internal_endpoint: Url,
+    handle: AsyncMutex<Option<RenewalHandle>>,
+    task: StoppableTaskPtr,
+}
+
+impl UpnpPortMapping {
+    /// Create a new UPnP port mapping instance
+    pub fn new(config: UpnpConfig, internal_endpoint: Url) -> Self {
+        Self {
+            config,
+            internal_endpoint,
+            handle: AsyncMutex::new(None),
+            task: StoppableTask::new(),
+        }
+    }
+
+    /// Main protocol loop - runs forever with retries
+    async fn run(&self, settings: Arc<AsyncRwLock<Settings>>, ex: &ExecutorPtr) -> Result<()> {
+        loop {
+            if self.try_create_mapping(ex).await.is_err() {
+                verbose!(
+                    target: "net::upnp",
+                    "[P2P] UPnP: Gateway discovery failed, retrying in {}s",
+                    self.config.retry_interval_secs
+                );
+                sleep(self.config.retry_interval_secs).await;
+                continue;
+            }
+
+            verbose!(
+                target: "net::upnp",
+                "[P2P] UPnP: Gateway discovered, mapping active for {}",
+                self.internal_endpoint
+            );
+
+            if self.run_refresh_loop(settings.clone()).await.is_err() {
+                verbose!(
+                    target: "net::upnp",
+                    "[P2P] UPnP: Gateway lost, retrying discovery in {}s",
+                    self.config.retry_interval_secs
+                );
+                sleep(self.config.retry_interval_secs).await;
+                continue;
+            }
+
+            unreachable!("UPnP refresh loop should never complete normally");
+        }
+    }
+
+    /// Attempt to discover gateway and create initial port mapping
+    async fn try_create_mapping(&self, ex: &ExecutorPtr) -> Result<()> {
+        let protocol = match self.internal_endpoint.scheme() {
+            "tcp" | "tcp+tls" => Protocol::TCP,
+            "quic" => Protocol::UDP,
+            s => {
+                verbose!(
+                    target: "net::upnp",
+                    "[P2P] UPnP: Unsupported scheme '{s}', skipping"
+                );
+                return Err(Error::NetworkServiceStopped);
+            }
+        };
+
+        // UPnP IGD port mapping is IPv4-only
+        let is_ipv4 = match self.internal_endpoint.host() {
+            Some(url::Host::Ipv4(_)) => true,
+            Some(url::Host::Ipv6(_)) => false,
+            // Treating domains as IPv4 is safe and generally useful
+            Some(url::Host::Domain(_)) => true,
+            None => false,
+        };
+
+        if !is_ipv4 {
+            verbose!(
+                target: "net::upnp",
+                "[P2P] UPnP: Skipping IPv6 endpoint {} (IGD pinhole not implemented)",
+                self.internal_endpoint
+            );
+            return Err(Error::NetworkServiceStopped);
+        }
+
+        let internal_port = match self.internal_endpoint.port() {
+            Some(port) => port,
+            None => {
+                verbose!(
+                    target: "net::upnp",
+                    "[P2P] UPnP: Invalid endpoint (missing port): {}",
+                    self.internal_endpoint
+                );
+                return Err(Error::NetworkServiceStopped);
+            }
+        };
+
+        let timeout = Duration::from_secs(self.config.discovery_timeout_secs);
+
+        verbose!(
+            target: "net::upnp",
+            "[P2P] UPnP: Attempting port mapping for internal port {}",
+            internal_port
+        );
+
+        // This will return immediately with a lazy handle
+        let handle = add_port_mapping_lazy(
+            ex.clone(),
+            internal_port,
+            protocol,
+            &self.config.mapping_description,
+            self.config.lease_duration,
+            timeout,
+        )
+        .await?;
+
+        *self.handle.lock().await = Some(handle);
+        Ok(())
+    }
+
+    /// Refresh loop - updates external address periodically
+    async fn run_refresh_loop(&self, settings: Arc<AsyncRwLock<Settings>>) -> Result<()> {
+        loop {
+            sleep(self.config.ext_addr_refresh).await;
+
+            let Some(external_url) = self.get_external_address().await else {
+                verbose!(
+                    target: "net::upnp",
+                    "[P2P] UPnP: Gateway no longer available"
+                );
+                return Err(Error::NetworkServiceStopped);
+            };
+
+            // Update settings with new external address
+            let mut settings = settings.write().await;
+
+            // Remove our old address (avoid duplicates)
+            let internal_id = format_address_id(&self.internal_endpoint, "upnp");
+            settings.external_addrs.retain(|addr: &Url| {
+                if let Some(query) = addr.query() {
+                    !query.contains(internal_id.as_str())
+                } else {
+                    true // Keep manually configured addresses
+                }
+            });
+
+            // Add new external address
+            settings.external_addrs.push(external_url.clone());
+
+            verbose!(
+                target: "net::upnp",
+                "[P2P] UPnP: Updated external address: {}",
+                external_url
+            );
+        }
+    }
+
+    /// Get current external address from UPnP handle
+    async fn get_external_address(&self) -> Option<Url> {
+        let handle = self.handle.lock().await;
+        let handle = handle.as_ref()?;
+
+        let external_ip = handle.external_ip().await;
+        if external_ip.is_unspecified() {
+            return None;
+        }
+
+        let external_port = handle.external_port();
+        if external_port == 0 {
+            return None;
+        }
+
+        let scheme = self.internal_endpoint.scheme();
+        let internal_id = format_address_id(&self.internal_endpoint, "upnp");
+
+        Url::parse(&format!(
+            "{}://{}:{}?source=upnp&{}",
+            scheme, external_ip, external_port, internal_id
+        ))
+        .ok()
+    }
+}
+
+#[async_trait]
+impl PortMapping for UpnpPortMapping {
+    fn start(self: Arc<Self>, settings: Arc<AsyncRwLock<Settings>>, ex: ExecutorPtr) -> Result<()> {
+        let self_ = self.clone();
+        let settings_ = settings.clone();
+        let ex_ = ex.clone();
+        self.task.clone().start(
+            async move { self_.run(settings_, &ex_).await },
+            |result| async move {
+                match result {
+                    Ok(()) => {
+                        // Should never complete normally
+                        error!("[P2P] UPnP task completed unexpectedly");
+                    }
+                    Err(Error::NetworkServiceStopped) => {
+                        // Expected when stopping
+                    }
+                    Err(e) => {
+                        error!("[P2P] UPnP task error: {e}");
+                    }
+                }
+            },
+            Error::NetworkServiceStopped,
+            ex,
+        );
+        Ok(())
+    }
+
+    fn stop(self: Arc<Self>) {
+        // Stop the task (synchronous, signals the task to stop)
+        self.task.stop_nowait();
+        // Handle dropped - mapping expires naturally
+        verbose!(
+            target: "net::upnp",
+            "[P2P] UPnP: Stopped port mapping for {}",
+            self.internal_endpoint
+        );
+    }
+}
+
+/// Format an identifier for this listener + protocol combination
+///
+/// This utility is shared across all port mapping protocols (UPnP, NAT-PMP, PCP)
+/// to create consistent, unique identifiers for external addresses.
+pub fn format_address_id(endpoint: &Url, protocol: &str) -> String {
+    // Hash the endpoint URL to create a unique alphanumeric identifier
+    let mut hasher = DefaultHasher::new();
+    endpoint.hash(&mut hasher);
+    let hash = hasher.finish();
+
+    format!("{}_cookie={:016x}", protocol, hash)
+}
+
+/// Create UPnP port mapping from URL query parameters
+pub fn create_upnp_from_url(url: &Url) -> Option<Arc<dyn PortMapping>> {
+    // Check if UPnP is explicitly enabled
+    if !url.query_pairs().any(|(key, value)| key == "upnp_igd" && value == "true") {
+        return None;
+    }
+
+    // Parse configuration from URL query parameters using safe URL library methods
+    let mut config = UpnpConfig::default();
+
+    for (key, value) in url.query_pairs() {
+        match key.as_ref() {
+            "upnp_igd_lease_duration" => {
+                if let Ok(val) = value.parse::<u32>() {
+                    config.lease_duration = val;
+                }
+            }
+            "upnp_igd_timeout" => {
+                if let Ok(val) = value.parse::<u64>() {
+                    config.discovery_timeout_secs = val;
+                }
+            }
+            "upnp_igd_description" => {
+                config.mapping_description = value.into_owned();
+            }
+            "upnp_igd_ext_addr_refresh" => {
+                if let Ok(val) = value.parse::<u64>() {
+                    config.ext_addr_refresh = val;
+                }
+            }
+            _ => {}
+        }
+    }
+
+    Some(Arc::new(UpnpPortMapping::new(config, url.clone())))
+}
+
+/// Initialize port mappings from URL query parameters.
+///
+/// This function parses the endpoint URL for port mapping configuration,
+/// creates the appropriate port mapping instances, and starts them.
+/// Each port mapping runs its own persistent task for lease renewal
+/// and external address updates.
+///
+/// # Examples
+/// ```text
+/// // Enable UPnP with defaults
+/// ?upnp_igd=true
+///
+/// // UPnP with custom settings
+/// ?upnp_igd=true&upnp_igd_lease_duration=600
+///
+/// // Multiple protocols
+/// ?upnp_igd=true&pcp=true
+/// ```
+///
+/// # Arguments
+/// * `endpoint` - The actual endpoint URL with query parameters and
+///   *assigned port*
+/// * `settings` - P2P settings for updating external addresses
+/// * `ex` - Executor for running async tasks
+///
+/// # Returns
+/// A vector of started port mappings (they auto-clean on drop)
+pub fn setup_port_mappings(
+    actual_endpoint: &Url,
+    settings: Arc<AsyncRwLock<Settings>>,
+    ex: ExecutorPtr,
+) -> Vec<Arc<dyn PortMapping>> {
+    let Some(mapping) = create_upnp_from_url(actual_endpoint) else { return vec![] };
+
+    if let Err(e) = Arc::clone(&mapping).start(settings.clone(), ex.clone()) {
+        error!(
+            target: "net::upnp",
+            "[P2P] UPnP port mapping: Failed to start for {}: {e}",
+            actual_endpoint
+        );
+        return vec![]
+    }
+
+    verbose!(
+        target: "net::upnp",
+        "[P2P] UPnP: Port mapping started for {}",
+        actual_endpoint
+    );
+    vec![mapping]
+
+    // Future: Add NAT-PMP, PCP here with similar patterns
+}