From 3156544828cb4f2ede5518f80725369439c01374 Mon Sep 17 00:00:00 2001 From: p4bpj Date: Tue, 19 Aug 2025 18:46:50 +0200 Subject: [PATCH 1/7] addressmanager: periodically refresh external UPnP IP and reconnect outbound peers on change Previously the external IP (via UPnP) was fetched and mapped only once at node startup; dynamic WAN IP changes (e.g. ISP 24h leases, router reconnects) were not propagated, leaving peers with a stale address until manual restart. This change: Adds periodic external IP refresh in the UPnP port mapping extender (re-uses the existing renew cycle to also call get_external_ip). Detects IP changes and updates the AddressManager best local address. Introduces ExternalIpChangeSink; ConnectionManager implements it to trigger a staggered outbound reconnect so new peers learn the updated public address sooner. Adds set_best_local_address helper. Adds CLI/config flag --disable-ipv6-interface-discovery to skip automatic IPv6 interface scanning while still allowing explicit IPv6 config. Registers ConnectionManager as an external IP change sink. --- components/addressmanager/src/lib.rs | 60 ++++++++++++--- .../src/port_mapping_extender.rs | 75 ++++++++++++++++++- components/connectionmanager/src/lib.rs | 65 +++++++++++++++- consensus/core/src/config/mod.rs | 4 + kaspad/src/args.rs | 5 ++ kaspad/src/daemon.rs | 8 +- protocol/flows/src/service.rs | 6 ++ 7 files changed, 208 insertions(+), 15 deletions(-) diff --git a/components/addressmanager/src/lib.rs b/components/addressmanager/src/lib.rs index b220a8ab1b..866272dc50 100644 --- a/components/addressmanager/src/lib.rs +++ b/components/addressmanager/src/lib.rs @@ -24,6 +24,10 @@ use thiserror::Error; pub use stores::NetAddress; +pub trait ExternalIpChangeSink: Send + Sync { + fn on_external_ip_changed(&self, new_ip: std::net::IpAddr, old_ip: Option); +} + const MAX_ADDRESSES: usize = 4096; const MAX_CONNECTION_FAILED_COUNT: u64 = 3; @@ -56,27 +60,39 @@ pub struct AddressManager { address_store: address_store_with_cache::Store, config: Arc, local_net_addresses: Vec, + external_ip_change_sinks: Vec>, } impl AddressManager { pub fn new(config: Arc, db: Arc, tick_service: Arc) -> (Arc>, Option) { - let mut instance = Self { + let instance = Self { banned_address_store: DbBannedAddressesStore::new(db.clone(), CachePolicy::Count(MAX_ADDRESSES)), address_store: address_store_with_cache::new(db), local_net_addresses: Vec::new(), + external_ip_change_sinks: Vec::new(), config, }; - let extender = instance.init_local_addresses(tick_service); + let am = Arc::new(Mutex::new(instance)); + let extender = Self::init_local_addresses_with_arc(&am, tick_service); - (Arc::new(Mutex::new(instance)), extender) + (am, extender) } - fn init_local_addresses(&mut self, tick_service: Arc) -> Option { - self.local_net_addresses = self.local_addresses().collect(); + pub fn register_external_ip_change_sink(&mut self, sink: Arc) { + self.external_ip_change_sinks.push(sink); + } - let extender = if self.local_net_addresses.is_empty() && !self.config.disable_upnp { - let (net_address, ExtendHelper { gateway, local_addr, external_port }) = match self.upnp() { + pub fn clone_external_ip_change_sinks(&self) -> Vec> { + self.external_ip_change_sinks.clone() + } + + fn init_local_addresses_with_arc(this: &Arc>, tick_service: Arc) -> Option { + let mut me = this.lock(); + me.local_net_addresses = me.local_addresses().collect(); + + let extender = if me.local_net_addresses.is_empty() && !me.config.disable_upnp { + let (net_address, ExtendHelper { gateway, local_addr, external_port }) = match me.upnp() { Err(err) => { warn!("[UPnP] Error adding port mapping: {err}"); return None; @@ -84,7 +100,7 @@ impl AddressManager { Ok(None) => return None, Ok(Some((net_address, extend_helper))) => (net_address, extend_helper), }; - self.local_net_addresses.push(net_address); + me.local_net_addresses.push(net_address); let gateway: igd_next::aio::Gateway = igd_next::aio::Gateway { addr: gateway.addr, @@ -101,12 +117,14 @@ impl AddressManager { gateway, external_port, local_addr, + Arc::clone(this), + Some(net_address.ip.into()), )) } else { None }; - self.local_net_addresses.iter().for_each(|net_addr| { + me.local_net_addresses.iter().for_each(|net_addr| { info!("Publicly routable local address {} added to store", net_addr); }); extender @@ -142,7 +160,19 @@ impl AddressManager { return Left(Right(iter::empty())); }; // TODO: Add Check IPv4 or IPv6 match from Go code - Right(network_interfaces.into_iter().map(|(_, ip)| IpAddress::from(ip)).filter(|&ip| ip.is_publicly_routable()).map( + Right(network_interfaces + .into_iter() + .map(|(_, ip)| IpAddress::from(ip)) + .filter(|ip| { + if self.config.disable_ipv6_interface_discovery { + // Skip IPv6 during automatic discovery if the flag is set + !matches!(**ip, std::net::IpAddr::V6(_)) + } else { + true + } + }) + .filter(|&ip| ip.is_publicly_routable()) + .map( |ip| { info!("Publicly routable local address found: {}", ip); NetAddress::new(ip, self.config.default_p2p_port()) @@ -155,7 +185,7 @@ impl AddressManager { fn upnp(&self) -> Result, UpnpError> { info!("[UPnP] Attempting to register upnp... (to disable run the node with --disable-upnp)"); - let gateway = igd::search_gateway(Default::default())?; + let gateway = igd::search_gateway (Default::default())?; let ip = IpAddress::new(gateway.get_external_ip()?); if !ip.is_publicly_routable() { info!("[UPnP] Non-publicly routable external ip from gateway using upnp {} not added to store", ip); @@ -251,6 +281,14 @@ impl AddressManager { } } + pub fn set_best_local_address(&mut self, address: NetAddress) { + if self.local_net_addresses.is_empty() { + self.local_net_addresses.push(address); + } else { + self.local_net_addresses[0] = address; + } + } + pub fn add_address(&mut self, address: NetAddress) { if address.ip.is_loopback() || address.ip.is_unspecified() { debug!("[Address manager] skipping local address {}", address.ip); diff --git a/components/addressmanager/src/port_mapping_extender.rs b/components/addressmanager/src/port_mapping_extender.rs index 11651bab93..0b8436beb2 100644 --- a/components/addressmanager/src/port_mapping_extender.rs +++ b/components/addressmanager/src/port_mapping_extender.rs @@ -10,9 +10,13 @@ use kaspa_core::{ use std::{net::SocketAddr, sync::Arc, time::Duration}; use crate::UPNP_REGISTRATION_NAME; +use crate::{AddressManager, NetAddress}; +use kaspa_utils::networking::IpAddress; +use parking_lot::Mutex; pub const SERVICE_NAME: &str = "port-mapping-extender"; +#[derive(Clone)] pub struct Extender { tick_service: Arc, fetch_interval: Duration, @@ -20,6 +24,8 @@ pub struct Extender { gateway: igd_next::aio::Gateway, external_port: u16, local_addr: SocketAddr, + address_manager: Arc>, + last_known_external_ip: Arc>>, } impl Extender { @@ -30,14 +36,31 @@ impl Extender { gateway: igd_next::aio::Gateway, external_port: u16, local_addr: SocketAddr, + address_manager: Arc>, + initial_external_ip: Option, ) -> Self { - Self { tick_service, fetch_interval, deadline_sec, gateway, external_port, local_addr } + // Log the initial IP for debugging + if let Some(initial_ip) = initial_external_ip { + debug!("[UPnP] Extender initialized with initial external IP: {}", initial_ip); + } + + Self { + tick_service, + fetch_interval, + deadline_sec, + gateway, + external_port, + local_addr, + address_manager, + last_known_external_ip: Arc::new(Mutex::new(initial_external_ip)), + } } } impl Extender { pub async fn worker(&self) -> Result<(), AddPortError> { while let TickReason::Wakeup = self.tick_service.tick(self.fetch_interval).await { + if let Err(e) = self .gateway .add_port( @@ -53,12 +76,62 @@ impl Extender { } else { debug!("[UPnP] Extend external ip mapping"); } + + let external_ip_result = self.gateway.get_external_ip().await; + if let Err(e) = &external_ip_result { + warn!("[UPnP] Fetch external ip err: {e:?}"); + } else { + debug!("[UPnP] Fetched external ip"); + } + + if let Ok(current_ip) = external_ip_result { + // Check if IP has changed + let ip_changed = { + let mut last_ip_guard = self.last_known_external_ip.lock(); + if *last_ip_guard != Some(current_ip) { + let old_ip = *last_ip_guard; + *last_ip_guard = Some(current_ip); + Some((current_ip, old_ip)) + } else { + None + } + }; // MutexGuard is dropped here + + if let Some((new_ip, old_ip)) = ip_changed { + self.handle_ip_change(new_ip, old_ip).await; + } + } + + } // Let the system print final logs before exiting tokio::time::sleep(Duration::from_millis(500)).await; trace!("{SERVICE_NAME} worker exiting"); Ok(()) } + + async fn handle_ip_change(&self, new_ip: std::net::IpAddr, old_ip: Option) { + info!("[UPnP] External IP changed from {:?} to {}", old_ip, new_ip); + + // Update best_local_address + let mut am_guard = self.address_manager.lock(); + let ip = IpAddress::new(new_ip); + let net_addr = NetAddress { ip, port: self.external_port }; + am_guard.set_best_local_address(net_addr); + debug!("[UPnP] Updated best local address to {}", net_addr); + + // Notify registered sinks (fire-and-forget). We offload each sync callback into its + // own lightweight task so the extender loop isn't delayed by sink logic. + let sinks = am_guard.clone_external_ip_change_sinks(); + drop(am_guard); + for sink in sinks { + let s = sink.clone(); + tokio::spawn(async move { + // Trait is sync; we just invoke inside an async task (fire-and-forget). + s.on_external_ip_changed(new_ip, old_ip); + }); + } + } } impl AsyncService for Extender { diff --git a/components/connectionmanager/src/lib.rs b/components/connectionmanager/src/lib.rs index 2146ec62d1..72bb2cf850 100644 --- a/components/connectionmanager/src/lib.rs +++ b/components/connectionmanager/src/lib.rs @@ -9,7 +9,7 @@ use std::{ use duration_string::DurationString; use futures_util::future::{join_all, try_join_all}; use itertools::Itertools; -use kaspa_addressmanager::{AddressManager, NetAddress}; +use kaspa_addressmanager::{AddressManager, NetAddress, ExternalIpChangeSink}; use kaspa_core::{debug, info, warn}; use kaspa_p2p_lib::{common::ProtocolError, ConnectionError, Peer}; use kaspa_utils::triggers::SingleTrigger; @@ -93,6 +93,63 @@ impl ConnectionManager { }); } + /// Synchronously trigger a staggered outbound reconnect (terminates peers one by one with 30s delays) + pub fn trigger_outbound_reconnect(&self) { + let outbound_peers: Vec<_> = self.p2p_adaptor.active_peers() + .into_iter() + .filter(|p| p.is_outbound()) + .collect(); + + if outbound_peers.is_empty() { + info!("No outbound peers to reconnect"); + return; + } + + let peer_count = outbound_peers.len(); + info!("Starting staggered outbound reconnect: {} peers will be renewed with 30s delays", peer_count); + + // Spawn async task for staggered renewal + let p2p_adaptor = self.p2p_adaptor.clone(); + let force_sender = self.force_next_iteration.clone(); + + tokio::spawn(async move { + for (i, peer) in outbound_peers.into_iter().enumerate() { + // Terminate peer + p2p_adaptor.terminate(peer.key()).await; + info!("Terminated outbound peer {} ({}/{})", peer.net_address(), i+1, peer_count); + + // Trigger reconnection (except for the last peer) + if i < peer_count - 1 { + force_sender.send(()).unwrap(); + + // Wait 30 seconds + tokio::time::sleep(Duration::from_secs(30)).await; + } + } + + // Final trigger for the last renewal + force_sender.send(()).unwrap(); + info!("Staggered outbound reconnect completed"); + }); + } + + /// Synchronously trigger an outbound reconnect iteration (no await required) + pub fn trigger_outbound_reconnect_simple(&self) { + info!("Connection manager: trigger outbound reconnect (sync)"); + if let Err(e) = self.force_next_iteration.send(()) { + warn!("Failed to trigger outbound reconnect: {}", e); + } + } + + /// Triggers gradual outbound reconnection to establish new connections with updated local address + pub async fn reconnect_outbound_gradually(self: &Arc) { + info!("Connection manager: triggering gradual outbound reconnection due to IP change"); + // Force next iteration to trigger new outbound connections + if let Err(e) = self.force_next_iteration.send(()) { + warn!("Failed to trigger outbound reconnection: {}", e); + } + } + async fn handle_event(self: Arc) { debug!("Starting connection loop iteration"); let peers = self.p2p_adaptor.active_peers(); @@ -338,3 +395,9 @@ impl ConnectionManager { self.connection_requests.lock().await.iter().any(|(address, request)| request.is_permanent && address.ip() == ip) } } + +impl ExternalIpChangeSink for ConnectionManager { + fn on_external_ip_changed(&self, _new_ip: std::net::IpAddr, _old_ip: Option) { + self.trigger_outbound_reconnect(); + } +} diff --git a/consensus/core/src/config/mod.rs b/consensus/core/src/config/mod.rs index bc41cde562..04581b13ca 100644 --- a/consensus/core/src/config/mod.rs +++ b/consensus/core/src/config/mod.rs @@ -66,6 +66,9 @@ pub struct Config { pub disable_upnp: bool, + /// Disable IPv6 during automatic local address discovery (explicit IPv6 in config is still honored) + pub disable_ipv6_interface_discovery: bool, + /// A scale factor to apply to memory allocation bounds pub ram_scale: f64, @@ -97,6 +100,7 @@ impl Config { #[cfg(feature = "devnet-prealloc")] initial_utxo_set: Default::default(), disable_upnp: false, + disable_ipv6_interface_discovery: false, ram_scale: 1.0, retention_period_days: None, } diff --git a/kaspad/src/args.rs b/kaspad/src/args.rs index 9a58bc6dbf..dd4f7c39fb 100644 --- a/kaspad/src/args.rs +++ b/kaspad/src/args.rs @@ -85,6 +85,7 @@ pub struct Args { pub prealloc_amount: u64, pub disable_upnp: bool, + pub disable_ipv6_interface_discovery: bool, #[serde(rename = "nodnsseed")] pub disable_dns_seeding: bool, #[serde(rename = "nogrpc")] @@ -138,6 +139,7 @@ impl Default for Args { prealloc_amount: 10_000_000_000, disable_upnp: false, + disable_ipv6_interface_discovery: false, disable_dns_seeding: false, disable_grpc: false, ram_scale: 1.0, @@ -150,6 +152,7 @@ impl Args { pub fn apply_to_config(&self, config: &mut Config) { config.utxoindex = self.utxoindex; config.disable_upnp = self.disable_upnp; + config.disable_ipv6_interface_discovery = self.disable_ipv6_interface_discovery; config.unsafe_rpc = self.unsafe_rpc; config.enable_unsynced_mining = self.enable_unsynced_mining; config.enable_mainnet_mining = self.enable_mainnet_mining; @@ -362,6 +365,7 @@ Setting to 0 prevents the preallocation and sets the maximum to {}, leading to 0 .help("Interval in seconds for performance metrics collection."), ) .arg(arg!(--"disable-upnp" "Disable upnp")) + .arg(arg!(--"disable-ipv6-interface-discovery" "Disable IPv6 during automatic local address discovery (explicit IPv6 in config is still honored)")) .arg(arg!(--"nodnsseed" "Disable DNS seeding for peers")) .arg(arg!(--"nogrpc" "Disable gRPC server")) .arg( @@ -455,6 +459,7 @@ impl Args { // Note: currently used programmatically by benchmarks and not exposed to CLI users block_template_cache_lifetime: defaults.block_template_cache_lifetime, disable_upnp: arg_match_unwrap_or::(&m, "disable-upnp", defaults.disable_upnp), + disable_ipv6_interface_discovery: arg_match_unwrap_or::(&m, "disable-ipv6-interface-discovery", defaults.disable_ipv6_interface_discovery), disable_dns_seeding: arg_match_unwrap_or::(&m, "nodnsseed", defaults.disable_dns_seeding), disable_grpc: arg_match_unwrap_or::(&m, "nogrpc", defaults.disable_grpc), ram_scale: arg_match_unwrap_or::(&m, "ram-scale", defaults.ram_scale), diff --git a/kaspad/src/daemon.rs b/kaspad/src/daemon.rs index 943a2b98ac..4bdec7beac 100644 --- a/kaspad/src/daemon.rs +++ b/kaspad/src/daemon.rs @@ -611,7 +611,7 @@ do you confirm? (answer y/n or pass --yes to the Kaspad command line to confirm notify_service.notifier(), index_service.as_ref().map(|x| x.notifier()), mining_manager, - flow_context, + flow_context.clone(), subscription_context, index_service.as_ref().map(|x| x.utxoindex().unwrap()), config.clone(), @@ -646,13 +646,14 @@ do you confirm? (answer y/n or pass --yes to the Kaspad command line to confirm if let Some(index_service) = index_service { async_runtime.register(index_service) }; + if let Some(port_mapping_extender_svc) = port_mapping_extender_svc { async_runtime.register(Arc::new(port_mapping_extender_svc)) }; async_runtime.register(rpc_core_service.clone()); if let Some(grpc_service) = grpc_service { async_runtime.register(grpc_service) - } + }; async_runtime.register(p2p_service); async_runtime.register(consensus_monitor); async_runtime.register(mining_monitor); @@ -683,6 +684,9 @@ do you confirm? (answer y/n or pass --yes to the Kaspad command line to confirm }) .for_each(|server| async_runtime.register(server)); + // Set up UPnP/DynDNS address change event handling after services are registered + // We'll handle this in the Extender itself by checking if ConnectionManager is available + // Consensus must start first in order to init genesis in stores core.bind(consensus_manager); core.bind(async_runtime); diff --git a/protocol/flows/src/service.rs b/protocol/flows/src/service.rs index 1633e26db0..3d5dc8a0da 100644 --- a/protocol/flows/src/service.rs +++ b/protocol/flows/src/service.rs @@ -80,6 +80,12 @@ impl AsyncService for P2pService { self.flow_context.address_manager.clone(), ); + // Register as sink for external IP changes + self.flow_context + .address_manager + .lock() + .register_external_ip_change_sink(connection_manager.clone()); + self.flow_context.set_connection_manager(connection_manager.clone()); self.flow_context.start_async_services(); From 09c6cd777278eb00b197bcf789d1fcfcb8d66037 Mon Sep 17 00:00:00 2001 From: p4bpj Date: Tue, 19 Aug 2025 20:57:50 +0200 Subject: [PATCH 2/7] AddressManager: add non-blocking DynDNS fallback with initial IPv4-pref resolve UPnP external IP refresh remains first attempt; if it fails, construct a DynDNS extender Perform a minimal synchronous DynDNS resolve in AddressManager (outside extender) to set initial external IP before P2P starts Make DynDnsExtender constructor non-blocking; periodic worker handles future changes and sink notifications Prefer IPv4 in Auto mode (otherwise honor configured IpVersionMode) --- .../addressmanager/src/dyndns_extender.rs | 122 ++++++++++++++++++ components/addressmanager/src/lib.rs | 70 +++++++++- consensus/core/src/config/mod.rs | 17 +++ kaspad/src/args.rs | 40 ++++++ kaspad/src/daemon.rs | 5 +- 5 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 components/addressmanager/src/dyndns_extender.rs diff --git a/components/addressmanager/src/dyndns_extender.rs b/components/addressmanager/src/dyndns_extender.rs new file mode 100644 index 0000000000..fa002f66b1 --- /dev/null +++ b/components/addressmanager/src/dyndns_extender.rs @@ -0,0 +1,122 @@ +use std::{net::IpAddr, sync::{Arc}, time::Duration}; +use parking_lot::Mutex; +use kaspa_core::{info, debug, warn, trace, task::{tick::{TickService, TickReason}, service::{AsyncService, AsyncServiceFuture}}}; +use crate::{NetAddress, AddressManager}; +use kaspa_utils::networking::IpAddress; +use kaspa_consensus_core::config::{Config, IpVersionMode}; + +pub const SERVICE_NAME: &str = "dyndns-extender"; + +// Simplistic resolver trait to allow mocking in tests later +trait DynResolver: Send + Sync { + fn resolve(&self, host: &str) -> std::io::Result>; +} + +struct DefaultResolver; +impl DynResolver for DefaultResolver { + fn resolve(&self, host: &str) -> std::io::Result> { Ok((host, 0).to_socket_addrs()?.map(|sa| sa.ip()).collect()) } +} +use std::net::ToSocketAddrs; // required for resolution via (host,0) + +pub struct DynDnsExtender { + tick_service: Arc, + address_manager: Arc>, + host: String, + min_refresh: Duration, + max_refresh: Duration, + ip_mode: IpVersionMode, + resolver: Box, + last_ip: Arc>>, +} + +impl DynDnsExtender { + pub fn new(config: Arc, am: Arc>, tick_service: Arc) -> Option { + let host = config.external_dyndns_host.clone()?; // only build if host provided + + // Create instance first so we can reuse pick_ip helper for initial synchronous resolution + let instance = Self { + tick_service, + address_manager: am.clone(), + min_refresh: Duration::from_secs(config.external_dyndns_min_refresh_sec), + max_refresh: Duration::from_secs(config.external_dyndns_max_refresh_sec), + ip_mode: config.external_dyndns_ip_version, + host, + resolver: Box::new(DefaultResolver), + last_ip: Arc::new(Mutex::new(None)), + }; + + // Perform an immediate (synchronous) resolution so the external IP is available before P2P starts. + // IMPORTANT: At this point no tokio runtime tasks should be spawned (runtime not fully registered yet), + // so we only set the best_local_address and remember last_ip. Sinks will be notified later on changes. + // No initial resolve here (done by AddressManager if desired). This constructor is now non-blocking. + + Some(instance) + } + + fn pick_ip(&self, mut ips: Vec) -> Option { + // filter publics + ips.retain(|ip| IpAddress::new(*ip).is_publicly_routable()); + if ips.is_empty() { return None; } + match self.ip_mode { + IpVersionMode::Ipv4 => ips.into_iter().find(|ip| matches!(ip, IpAddr::V4(_))), + IpVersionMode::Ipv6 => ips.into_iter().find(|ip| matches!(ip, IpAddr::V6(_))), + IpVersionMode::Auto => { + if let Some(v4) = ips.iter().cloned().find(|ip| matches!(ip, IpAddr::V4(_))) { return Some(v4); } + ips.into_iter().next() + } + } + } + + async fn worker(&self) { + info!("[DynDNS] Starting dyn dns resolver for host {}", self.host); + let mut interval = self.min_refresh; // adaptive later + loop { + match self.tick_service.tick(interval).await { + TickReason::Shutdown => break, + TickReason::Wakeup => {} + } + match self.resolver.resolve(&self.host) { + Ok(ips) => { + debug!("[DynDNS] Resolved {} -> {:?}", self.host, ips); + let picked = self.pick_ip(ips); + if let Some(new_ip) = picked { + let mut last_guard = self.last_ip.lock(); + if Some(new_ip) != *last_guard { + let old_ip = *last_guard; + *last_guard = Some(new_ip); + drop(last_guard); + self.apply_new_ip(new_ip, old_ip); + } + interval = self.min_refresh; // reset + } else { + warn!("[DynDNS] No public IP obtained for {}", self.host); + interval = std::cmp::min(interval * 2, self.max_refresh); + } + } + Err(e) => { + warn!("[DynDNS] Resolve failed for {}: {e}", self.host); + interval = std::cmp::min(interval * 2, self.max_refresh); + } + } + } + trace!("{SERVICE_NAME} worker exiting"); + } + + fn apply_new_ip(&self, new_ip: IpAddr, old_ip: Option) { + info!("[DynDNS] External IP changed {:?} -> {}", old_ip, new_ip); + let mut am = self.address_manager.lock(); + let port = am.best_local_address().map(|a| a.port).unwrap_or_else(|| am.config.default_p2p_port()); + let net = NetAddress::new(new_ip.into(), port); + am.set_best_local_address(net); + let sinks = am.clone_external_ip_change_sinks(); + drop(am); + for sink in sinks { let s = sink.clone(); tokio::spawn(async move { s.on_external_ip_changed(new_ip, old_ip); }); } + } +} + +impl AsyncService for DynDnsExtender { + fn ident(self: Arc) -> &'static str { SERVICE_NAME } + fn start(self: Arc) -> AsyncServiceFuture { Box::pin(async move { self.worker().await; Ok(()) }) } + fn signal_exit(self: Arc) { trace!("sending an exit signal to {}", SERVICE_NAME); } + fn stop(self: Arc) -> AsyncServiceFuture { Box::pin(async move { trace!("{} stopped", SERVICE_NAME); Ok(()) }) } +} diff --git a/components/addressmanager/src/lib.rs b/components/addressmanager/src/lib.rs index 866272dc50..7cf9908d8a 100644 --- a/components/addressmanager/src/lib.rs +++ b/components/addressmanager/src/lib.rs @@ -1,10 +1,12 @@ mod port_mapping_extender; +mod dyndns_extender; mod stores; extern crate self as address_manager; -use std::{collections::HashSet, iter, net::SocketAddr, sync::Arc, time::Duration}; +use std::{collections::HashSet, iter, net::SocketAddr, sync::Arc, time::{Duration, Instant}}; use address_manager::port_mapping_extender::Extender; +use dyndns_extender::DynDnsExtender; use igd_next::{ self as igd, aio::tokio::Tokio, AddAnyPortError, AddPortError, Gateway, GetExternalIpError, GetGenericPortMappingEntryError, SearchError, @@ -13,7 +15,7 @@ use itertools::{ Either::{Left, Right}, Itertools, }; -use kaspa_consensus_core::config::Config; +use kaspa_consensus_core::config::{Config, IpVersionMode}; use kaspa_core::{debug, info, task::tick::TickService, time::unix_now, warn}; use kaspa_database::prelude::{CachePolicy, StoreResultExtensions, DB}; use kaspa_utils::networking::IpAddress; @@ -64,7 +66,8 @@ pub struct AddressManager { } impl AddressManager { - pub fn new(config: Arc, db: Arc, tick_service: Arc) -> (Arc>, Option) { + pub fn new(config: Arc, db: Arc, tick_service: Arc) -> (Arc>, Option, Option) { + debug!("[AddrMan] Enter AddressManager::new"); let instance = Self { banned_address_store: DbBannedAddressesStore::new(db.clone(), CachePolicy::Count(MAX_ADDRESSES)), address_store: address_store_with_cache::new(db), @@ -74,9 +77,58 @@ impl AddressManager { }; let am = Arc::new(Mutex::new(instance)); - let extender = Self::init_local_addresses_with_arc(&am, tick_service); + let extender = Self::init_local_addresses_with_arc(&am, tick_service.clone()); + let dyndns_extender = if extender.is_none() && am.lock().config.external_dyndns_host.is_some() { + debug!("[AddrMan] No UPnP extender; attempting DynDnsExtender construction (host present)"); + let res = DynDnsExtender::new(am.lock().config.clone(), am.clone(), tick_service); + if res.is_some() { debug!("[AddrMan] DynDnsExtender constructed"); } else { debug!("[AddrMan] DynDnsExtender NOT constructed (unexpected None)"); } + res + } else { + if extender.is_some() { debug!("[AddrMan] UPnP extender active; DynDNS skipped"); } + else { debug!("[AddrMan] No extender and no DynDNS host configured"); } + None + }; + // Attempt minimal initial DynDNS resolve (fallback) if applicable + if extender.is_none() { Self::try_initial_dyndns_resolve(&am); } + debug!("[AddrMan] Exit AddressManager::new (upnp_extender={} dyndns_extender={})", extender.is_some(), dyndns_extender.is_some()); + (am, extender, dyndns_extender) + } - (am, extender) + fn try_initial_dyndns_resolve(am: &Arc>) { + let host_opt = am.lock().config.external_dyndns_host.clone(); + let Some(host) = host_opt else { return }; // no host + let ip_mode = am.lock().config.external_dyndns_ip_version; // copy (enum is Copy) + let needs = { am.lock().best_local_address().is_none() }; + if !needs { return; } + debug!("[AddrMan] Performing minimal initial DynDNS resolve for host {}", host); + // DNS resolution outside lock + let result: std::io::Result> = (|| { + use std::net::ToSocketAddrs; (host.as_str(), 0).to_socket_addrs().map(|it| it.map(|sa| sa.ip()).collect()) + })(); + match result { + Ok(mut ips) => { + ips.retain(|ip| IpAddress::new(*ip).is_publicly_routable()); + // Selection respecting IpVersionMode (mirrors DynDnsExtender logic) + let selected = match ip_mode { + IpVersionMode::Ipv4 => ips.iter().cloned().find(|ip| matches!(ip, std::net::IpAddr::V4(_))), + IpVersionMode::Ipv6 => ips.iter().cloned().find(|ip| matches!(ip, std::net::IpAddr::V6(_))), + IpVersionMode::Auto => { + if let Some(v4) = ips.iter().cloned().find(|ip| matches!(ip, std::net::IpAddr::V4(_))) { Some(v4) } else { ips.get(0).cloned() } + } + }; + if let Some(ip) = selected { + let mut guard = am.lock(); + if guard.best_local_address().is_none() { + let port = guard.config.default_p2p_port(); + guard.set_best_local_address(NetAddress::new(ip.into(), port)); + debug!("[AddrMan] Initial DynDNS external IP set to {}:{}", ip, port); + } + } else { + debug!("[AddrMan] Initial DynDNS resolve returned no public addresses"); + } + } + Err(e) => debug!("[AddrMan] Initial DynDNS resolve failed: {e}"), + } } pub fn register_external_ip_change_sink(&mut self, sink: Arc) { @@ -89,17 +141,22 @@ impl AddressManager { fn init_local_addresses_with_arc(this: &Arc>, tick_service: Arc) -> Option { let mut me = this.lock(); + debug!("[AddrMan] init_local_addresses_with_arc start"); me.local_net_addresses = me.local_addresses().collect(); let extender = if me.local_net_addresses.is_empty() && !me.config.disable_upnp { + debug!("[AddrMan] No local routable addresses; UPnP enabled -> invoking upnp()"); + let t0 = Instant::now(); let (net_address, ExtendHelper { gateway, local_addr, external_port }) = match me.upnp() { Err(err) => { warn!("[UPnP] Error adding port mapping: {err}"); + debug!("[AddrMan] upnp() failed after {:?}", t0.elapsed()); return None; } Ok(None) => return None, Ok(Some((net_address, extend_helper))) => (net_address, extend_helper), }; + debug!("[AddrMan] upnp() succeeded in {:?}", t0.elapsed()); me.local_net_addresses.push(net_address); let gateway: igd_next::aio::Gateway = igd_next::aio::Gateway { @@ -127,6 +184,7 @@ impl AddressManager { me.local_net_addresses.iter().for_each(|net_addr| { info!("Publicly routable local address {} added to store", net_addr); }); + debug!("[AddrMan] init_local_addresses_with_arc end (extender={})", extender.is_some()); extender } @@ -586,7 +644,7 @@ mod address_store_with_cache { let db = create_temp_db!(ConnBuilder::default().with_files_limit(10)); let config = Config::new(SIMNET_PARAMS); - let (am, _) = AddressManager::new(Arc::new(config), db.1, Arc::new(TickService::default())); + let (am, _, _) = AddressManager::new(Arc::new(config), db.1, Arc::new(TickService::default())); let mut am_guard = am.lock(); diff --git a/consensus/core/src/config/mod.rs b/consensus/core/src/config/mod.rs index 04581b13ca..a875daee3f 100644 --- a/consensus/core/src/config/mod.rs +++ b/consensus/core/src/config/mod.rs @@ -17,6 +17,13 @@ use { params::Params, }; +#[derive(Clone, Copy, Debug)] +pub enum IpVersionMode { + Auto, + Ipv4, + Ipv6, +} + /// Various consensus configurations all bundled up under a single struct. Use `Config::new` for directly building from /// a `Params` instance. For anything more complex it is recommended to use `ConfigBuilder`. NOTE: this struct can be /// implicitly de-refed into `Params` @@ -68,6 +75,12 @@ pub struct Config { /// Disable IPv6 during automatic local address discovery (explicit IPv6 in config is still honored) pub disable_ipv6_interface_discovery: bool, + // DynDNS based external IP resolution (optional hostname). If set and UPnP fails to yield a public IP, + // a dyn dns resolver service will periodically resolve this host and update the external address on change. + pub external_dyndns_host: Option, + pub external_dyndns_min_refresh_sec: u64, + pub external_dyndns_max_refresh_sec: u64, + pub external_dyndns_ip_version: IpVersionMode, /// A scale factor to apply to memory allocation bounds pub ram_scale: f64, @@ -101,6 +114,10 @@ impl Config { initial_utxo_set: Default::default(), disable_upnp: false, disable_ipv6_interface_discovery: false, + external_dyndns_host: None, + external_dyndns_min_refresh_sec: 30, + external_dyndns_max_refresh_sec: 300, + external_dyndns_ip_version: IpVersionMode::Auto, ram_scale: 1.0, retention_period_days: None, } diff --git a/kaspad/src/args.rs b/kaspad/src/args.rs index dd4f7c39fb..e8effc4b15 100644 --- a/kaspad/src/args.rs +++ b/kaspad/src/args.rs @@ -86,6 +86,11 @@ pub struct Args { pub disable_upnp: bool, pub disable_ipv6_interface_discovery: bool, + // DynDNS external IP resolution arguments + pub external_dyndns_host: Option, + pub external_dyndns_min_refresh_sec: u64, + pub external_dyndns_max_refresh_sec: u64, + pub external_dyndns_ip_version: String, // parsed into enum later (auto|ipv4|ipv6) #[serde(rename = "nodnsseed")] pub disable_dns_seeding: bool, #[serde(rename = "nogrpc")] @@ -98,6 +103,10 @@ impl Default for Args { fn default() -> Self { Self { appdir: None, + external_dyndns_host: None, + external_dyndns_min_refresh_sec: 30, + external_dyndns_max_refresh_sec: 300, + external_dyndns_ip_version: "auto".to_string(), no_log_files: false, rpclisten_borsh: None, rpclisten_json: None, @@ -165,6 +174,14 @@ impl Args { config.externalip = self.externalip.map(|v| v.normalize(config.default_p2p_port())); config.ram_scale = self.ram_scale; config.retention_period_days = self.retention_period_days; + config.external_dyndns_host = self.external_dyndns_host.clone(); + config.external_dyndns_min_refresh_sec = self.external_dyndns_min_refresh_sec; + config.external_dyndns_max_refresh_sec = self.external_dyndns_max_refresh_sec; + config.external_dyndns_ip_version = match self.external_dyndns_ip_version.as_str() { + "ipv4" => kaspa_consensus_core::config::IpVersionMode::Ipv4, + "ipv6" => kaspa_consensus_core::config::IpVersionMode::Ipv6, + _ => kaspa_consensus_core::config::IpVersionMode::Auto, + }; #[cfg(feature = "devnet-prealloc")] if let Some(num_prealloc_utxos) = self.num_prealloc_utxos { @@ -366,6 +383,25 @@ Setting to 0 prevents the preallocation and sets the maximum to {}, leading to 0 ) .arg(arg!(--"disable-upnp" "Disable upnp")) .arg(arg!(--"disable-ipv6-interface-discovery" "Disable IPv6 during automatic local address discovery (explicit IPv6 in config is still honored)")) + .arg(arg!(--"external-dyndns-host" "DynDNS host to resolve periodically as fallback external IP source when UPnP fails")) + .arg( + Arg::new("external-dyndns-min-refresh") + .long("external-dyndns-min-refresh") + .value_parser(clap::value_parser!(u64)) + .help("Minimum refresh interval (seconds) for DynDNS resolution (default 30)") + ) + .arg( + Arg::new("external-dyndns-max-refresh") + .long("external-dyndns-max-refresh") + .value_parser(clap::value_parser!(u64)) + .help("Maximum refresh interval (seconds) for DynDNS resolution (default 300)") + ) + .arg( + Arg::new("external-dyndns-ip-version") + .long("external-dyndns-ip-version") + .value_parser(["auto","ipv4","ipv6"]) + .help("IP version selection for DynDNS resolution: auto|ipv4|ipv6 (default auto)") + ) .arg(arg!(--"nodnsseed" "Disable DNS seeding for peers")) .arg(arg!(--"nogrpc" "Disable gRPC server")) .arg( @@ -464,6 +500,10 @@ impl Args { disable_grpc: arg_match_unwrap_or::(&m, "nogrpc", defaults.disable_grpc), ram_scale: arg_match_unwrap_or::(&m, "ram-scale", defaults.ram_scale), retention_period_days: m.get_one::("retention-period-days").cloned().or(defaults.retention_period_days), + external_dyndns_host: m.get_one::("external-dyndns-host").cloned().or(defaults.external_dyndns_host), + external_dyndns_min_refresh_sec: arg_match_unwrap_or::(&m, "external-dyndns-min-refresh", defaults.external_dyndns_min_refresh_sec), + external_dyndns_max_refresh_sec: arg_match_unwrap_or::(&m, "external-dyndns-max-refresh", defaults.external_dyndns_max_refresh_sec), + external_dyndns_ip_version: arg_match_unwrap_or::(&m, "external-dyndns-ip-version", defaults.external_dyndns_ip_version.clone()), #[cfg(feature = "devnet-prealloc")] num_prealloc_utxos: m.get_one::("num-prealloc-utxos").cloned(), diff --git a/kaspad/src/daemon.rs b/kaspad/src/daemon.rs index 4bdec7beac..5893918761 100644 --- a/kaspad/src/daemon.rs +++ b/kaspad/src/daemon.rs @@ -557,7 +557,7 @@ do you confirm? (answer y/n or pass --yes to the Kaspad command line to confirm None }; - let (address_manager, port_mapping_extender_svc) = AddressManager::new(config.clone(), meta_db, tick_service.clone()); + let (address_manager, port_mapping_extender_svc, dyndns_extender_svc) = AddressManager::new(config.clone(), meta_db, tick_service.clone()); let mining_manager = MiningManagerProxy::new(Arc::new(MiningManager::new_with_extended_config( config.target_time_per_block(), @@ -650,6 +650,9 @@ do you confirm? (answer y/n or pass --yes to the Kaspad command line to confirm if let Some(port_mapping_extender_svc) = port_mapping_extender_svc { async_runtime.register(Arc::new(port_mapping_extender_svc)) }; + if let Some(dyndns_extender_svc) = dyndns_extender_svc { + async_runtime.register(Arc::new(dyndns_extender_svc)); + } async_runtime.register(rpc_core_service.clone()); if let Some(grpc_service) = grpc_service { async_runtime.register(grpc_service) From 3b3a03f51e4c86a2c9d6666a61c4847e95c52db6 Mon Sep 17 00:00:00 2001 From: p4bpj Date: Wed, 20 Aug 2025 19:58:55 +0200 Subject: [PATCH 3/7] AddressManager: simplify init; drop unused outbound reconnect helpers - Rename init_local_addresses_with_arc -> init_local_addresses (Arc param made name redundant). - Remove unused ConnectionManager methods trigger_outbound_reconnect_simple and reconnect_outbound_gradually (logic now handled internally elsewhere). - Clean up stale UPnP/DynDNS setup comment in daemon. - Minor formatting tidy in UPnP gateway search call. --- components/addressmanager/src/lib.rs | 6 +++--- components/connectionmanager/src/lib.rs | 17 ----------------- kaspad/src/daemon.rs | 3 --- 3 files changed, 3 insertions(+), 23 deletions(-) diff --git a/components/addressmanager/src/lib.rs b/components/addressmanager/src/lib.rs index 866272dc50..e2915ea2b0 100644 --- a/components/addressmanager/src/lib.rs +++ b/components/addressmanager/src/lib.rs @@ -74,7 +74,7 @@ impl AddressManager { }; let am = Arc::new(Mutex::new(instance)); - let extender = Self::init_local_addresses_with_arc(&am, tick_service); + let extender = Self::init_local_addresses(&am, tick_service); (am, extender) } @@ -87,7 +87,7 @@ impl AddressManager { self.external_ip_change_sinks.clone() } - fn init_local_addresses_with_arc(this: &Arc>, tick_service: Arc) -> Option { + fn init_local_addresses(this: &Arc>, tick_service: Arc) -> Option { let mut me = this.lock(); me.local_net_addresses = me.local_addresses().collect(); @@ -185,7 +185,7 @@ impl AddressManager { fn upnp(&self) -> Result, UpnpError> { info!("[UPnP] Attempting to register upnp... (to disable run the node with --disable-upnp)"); - let gateway = igd::search_gateway (Default::default())?; + let gateway = igd::search_gateway(Default::default())?; let ip = IpAddress::new(gateway.get_external_ip()?); if !ip.is_publicly_routable() { info!("[UPnP] Non-publicly routable external ip from gateway using upnp {} not added to store", ip); diff --git a/components/connectionmanager/src/lib.rs b/components/connectionmanager/src/lib.rs index 72bb2cf850..f741a776b1 100644 --- a/components/connectionmanager/src/lib.rs +++ b/components/connectionmanager/src/lib.rs @@ -133,23 +133,6 @@ impl ConnectionManager { }); } - /// Synchronously trigger an outbound reconnect iteration (no await required) - pub fn trigger_outbound_reconnect_simple(&self) { - info!("Connection manager: trigger outbound reconnect (sync)"); - if let Err(e) = self.force_next_iteration.send(()) { - warn!("Failed to trigger outbound reconnect: {}", e); - } - } - - /// Triggers gradual outbound reconnection to establish new connections with updated local address - pub async fn reconnect_outbound_gradually(self: &Arc) { - info!("Connection manager: triggering gradual outbound reconnection due to IP change"); - // Force next iteration to trigger new outbound connections - if let Err(e) = self.force_next_iteration.send(()) { - warn!("Failed to trigger outbound reconnection: {}", e); - } - } - async fn handle_event(self: Arc) { debug!("Starting connection loop iteration"); let peers = self.p2p_adaptor.active_peers(); diff --git a/kaspad/src/daemon.rs b/kaspad/src/daemon.rs index 4bdec7beac..0cb0b65e30 100644 --- a/kaspad/src/daemon.rs +++ b/kaspad/src/daemon.rs @@ -684,9 +684,6 @@ do you confirm? (answer y/n or pass --yes to the Kaspad command line to confirm }) .for_each(|server| async_runtime.register(server)); - // Set up UPnP/DynDNS address change event handling after services are registered - // We'll handle this in the Extender itself by checking if ConnectionManager is available - // Consensus must start first in order to init genesis in stores core.bind(consensus_manager); core.bind(async_runtime); From 6d7d58c5584cef6917141b99731b457ed836fc44 Mon Sep 17 00:00:00 2001 From: p4bpj Date: Wed, 20 Aug 2025 21:01:13 +0200 Subject: [PATCH 4/7] Refactor DynDnsExtender and AddressManager: remove unused imports and debug statements --- components/addressmanager/src/dyndns_extender.rs | 10 ++-------- components/addressmanager/src/lib.rs | 14 +++----------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/components/addressmanager/src/dyndns_extender.rs b/components/addressmanager/src/dyndns_extender.rs index fa002f66b1..0d71585741 100644 --- a/components/addressmanager/src/dyndns_extender.rs +++ b/components/addressmanager/src/dyndns_extender.rs @@ -4,6 +4,7 @@ use kaspa_core::{info, debug, warn, trace, task::{tick::{TickService, TickReason use crate::{NetAddress, AddressManager}; use kaspa_utils::networking::IpAddress; use kaspa_consensus_core::config::{Config, IpVersionMode}; +use std::net::ToSocketAddrs; pub const SERVICE_NAME: &str = "dyndns-extender"; @@ -16,7 +17,6 @@ struct DefaultResolver; impl DynResolver for DefaultResolver { fn resolve(&self, host: &str) -> std::io::Result> { Ok((host, 0).to_socket_addrs()?.map(|sa| sa.ip()).collect()) } } -use std::net::ToSocketAddrs; // required for resolution via (host,0) pub struct DynDnsExtender { tick_service: Arc, @@ -33,7 +33,6 @@ impl DynDnsExtender { pub fn new(config: Arc, am: Arc>, tick_service: Arc) -> Option { let host = config.external_dyndns_host.clone()?; // only build if host provided - // Create instance first so we can reuse pick_ip helper for initial synchronous resolution let instance = Self { tick_service, address_manager: am.clone(), @@ -45,11 +44,6 @@ impl DynDnsExtender { last_ip: Arc::new(Mutex::new(None)), }; - // Perform an immediate (synchronous) resolution so the external IP is available before P2P starts. - // IMPORTANT: At this point no tokio runtime tasks should be spawned (runtime not fully registered yet), - // so we only set the best_local_address and remember last_ip. Sinks will be notified later on changes. - // No initial resolve here (done by AddressManager if desired). This constructor is now non-blocking. - Some(instance) } @@ -68,7 +62,7 @@ impl DynDnsExtender { } async fn worker(&self) { - info!("[DynDNS] Starting dyn dns resolver for host {}", self.host); + info!("[DynDNS] Starting dyndns resolver for host {}", self.host); let mut interval = self.min_refresh; // adaptive later loop { match self.tick_service.tick(interval).await { diff --git a/components/addressmanager/src/lib.rs b/components/addressmanager/src/lib.rs index 019ee3ef3a..ab4778f5ed 100644 --- a/components/addressmanager/src/lib.rs +++ b/components/addressmanager/src/lib.rs @@ -3,7 +3,7 @@ mod dyndns_extender; mod stores; extern crate self as address_manager; -use std::{collections::HashSet, iter, net::SocketAddr, sync::Arc, time::{Duration, Instant}}; +use std::{collections::HashSet, iter, net::SocketAddr, sync::Arc, time::{Duration}}; use address_manager::port_mapping_extender::Extender; use dyndns_extender::DynDnsExtender; @@ -67,7 +67,6 @@ pub struct AddressManager { impl AddressManager { pub fn new(config: Arc, db: Arc, tick_service: Arc) -> (Arc>, Option, Option) { - debug!("[AddrMan] Enter AddressManager::new"); let instance = Self { banned_address_store: DbBannedAddressesStore::new(db.clone(), CachePolicy::Count(MAX_ADDRESSES)), address_store: address_store_with_cache::new(db), @@ -80,7 +79,6 @@ impl AddressManager { let extender = Self::init_local_addresses(&am, tick_service.clone()); let dyndns_extender = if extender.is_none() && am.lock().config.external_dyndns_host.is_some() { - debug!("[AddrMan] No UPnP extender; attempting DynDnsExtender construction (host present)"); let res = DynDnsExtender::new(am.lock().config.clone(), am.clone(), tick_service); if res.is_some() { debug!("[AddrMan] DynDnsExtender constructed"); } else { debug!("[AddrMan] DynDnsExtender NOT constructed (unexpected None)"); } res @@ -97,8 +95,8 @@ impl AddressManager { fn try_initial_dyndns_resolve(am: &Arc>) { let host_opt = am.lock().config.external_dyndns_host.clone(); - let Some(host) = host_opt else { return }; // no host - let ip_mode = am.lock().config.external_dyndns_ip_version; // copy (enum is Copy) + let Some(host) = host_opt else { return }; + let ip_mode = am.lock().config.external_dyndns_ip_version; let needs = { am.lock().best_local_address().is_none() }; if !needs { return; } debug!("[AddrMan] Performing minimal initial DynDNS resolve for host {}", host); @@ -142,22 +140,17 @@ impl AddressManager { fn init_local_addresses(this: &Arc>, tick_service: Arc) -> Option { let mut me = this.lock(); - debug!("[AddrMan] init_local_addresses_with_arc start"); me.local_net_addresses = me.local_addresses().collect(); let extender = if me.local_net_addresses.is_empty() && !me.config.disable_upnp { - debug!("[AddrMan] No local routable addresses; UPnP enabled -> invoking upnp()"); - let t0 = Instant::now(); let (net_address, ExtendHelper { gateway, local_addr, external_port }) = match me.upnp() { Err(err) => { warn!("[UPnP] Error adding port mapping: {err}"); - debug!("[AddrMan] upnp() failed after {:?}", t0.elapsed()); return None; } Ok(None) => return None, Ok(Some((net_address, extend_helper))) => (net_address, extend_helper), }; - debug!("[AddrMan] upnp() succeeded in {:?}", t0.elapsed()); me.local_net_addresses.push(net_address); let gateway: igd_next::aio::Gateway = igd_next::aio::Gateway { @@ -185,7 +178,6 @@ impl AddressManager { me.local_net_addresses.iter().for_each(|net_addr| { info!("Publicly routable local address {} added to store", net_addr); }); - debug!("[AddrMan] init_local_addresses_with_arc end (extender={})", extender.is_some()); extender } From 5ea175e5156f645f7258279441780a17bfb8c10d Mon Sep 17 00:00:00 2001 From: p4bpj Date: Wed, 20 Aug 2025 21:47:14 +0200 Subject: [PATCH 5/7] args: require equals for DynDNS host and refresh interval arguments --- kaspad/src/args.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kaspad/src/args.rs b/kaspad/src/args.rs index e8effc4b15..ae22c31eaa 100644 --- a/kaspad/src/args.rs +++ b/kaspad/src/args.rs @@ -383,22 +383,25 @@ Setting to 0 prevents the preallocation and sets the maximum to {}, leading to 0 ) .arg(arg!(--"disable-upnp" "Disable upnp")) .arg(arg!(--"disable-ipv6-interface-discovery" "Disable IPv6 during automatic local address discovery (explicit IPv6 in config is still honored)")) - .arg(arg!(--"external-dyndns-host" "DynDNS host to resolve periodically as fallback external IP source when UPnP fails")) + .arg(arg!(--"external-dyndns-host" "DynDNS host to resolve periodically as fallback external IP source when UPnP fails").require_equals(true)) .arg( Arg::new("external-dyndns-min-refresh") .long("external-dyndns-min-refresh") + .require_equals(true) .value_parser(clap::value_parser!(u64)) .help("Minimum refresh interval (seconds) for DynDNS resolution (default 30)") ) .arg( Arg::new("external-dyndns-max-refresh") .long("external-dyndns-max-refresh") + .require_equals(true) .value_parser(clap::value_parser!(u64)) .help("Maximum refresh interval (seconds) for DynDNS resolution (default 300)") ) .arg( Arg::new("external-dyndns-ip-version") .long("external-dyndns-ip-version") + .require_equals(true) .value_parser(["auto","ipv4","ipv6"]) .help("IP version selection for DynDNS resolution: auto|ipv4|ipv6 (default auto)") ) From 3e3bb1d65c272bdb92c4dd38afb92f88c093badc Mon Sep 17 00:00:00 2001 From: p4bpj Date: Sun, 24 Aug 2025 19:50:27 +0200 Subject: [PATCH 6/7] DynDnsExtender: add initial external IP parameter to constructor and update AddressManager initialization logic --- .../addressmanager/src/dyndns_extender.rs | 4 +- components/addressmanager/src/lib.rs | 104 ++++++++++-------- 2 files changed, 62 insertions(+), 46 deletions(-) diff --git a/components/addressmanager/src/dyndns_extender.rs b/components/addressmanager/src/dyndns_extender.rs index 0d71585741..ce283b881d 100644 --- a/components/addressmanager/src/dyndns_extender.rs +++ b/components/addressmanager/src/dyndns_extender.rs @@ -30,7 +30,7 @@ pub struct DynDnsExtender { } impl DynDnsExtender { - pub fn new(config: Arc, am: Arc>, tick_service: Arc) -> Option { + pub fn new(config: Arc, am: Arc>, tick_service: Arc, initial_external_ip: Option) -> Option { let host = config.external_dyndns_host.clone()?; // only build if host provided let instance = Self { @@ -41,7 +41,7 @@ impl DynDnsExtender { ip_mode: config.external_dyndns_ip_version, host, resolver: Box::new(DefaultResolver), - last_ip: Arc::new(Mutex::new(None)), + last_ip: Arc::new(Mutex::new(initial_external_ip)), }; Some(instance) diff --git a/components/addressmanager/src/lib.rs b/components/addressmanager/src/lib.rs index ab4778f5ed..79cb42ebb3 100644 --- a/components/addressmanager/src/lib.rs +++ b/components/addressmanager/src/lib.rs @@ -1,9 +1,9 @@ -mod port_mapping_extender; mod dyndns_extender; +mod port_mapping_extender; mod stores; extern crate self as address_manager; -use std::{collections::HashSet, iter, net::SocketAddr, sync::Arc, time::{Duration}}; +use std::{collections::HashSet, iter, net::SocketAddr, sync::Arc, time::Duration}; use address_manager::port_mapping_extender::Extender; use dyndns_extender::DynDnsExtender; @@ -27,7 +27,7 @@ use thiserror::Error; pub use stores::NetAddress; pub trait ExternalIpChangeSink: Send + Sync { - fn on_external_ip_changed(&self, new_ip: std::net::IpAddr, old_ip: Option); + fn on_external_ip_changed(&self, new_ip: std::net::IpAddr, old_ip: Option); } const MAX_ADDRESSES: usize = 4096; @@ -66,7 +66,11 @@ pub struct AddressManager { } impl AddressManager { - pub fn new(config: Arc, db: Arc, tick_service: Arc) -> (Arc>, Option, Option) { + pub fn new( + config: Arc, + db: Arc, + tick_service: Arc, + ) -> (Arc>, Option, Option) { let instance = Self { banned_address_store: DbBannedAddressesStore::new(db.clone(), CachePolicy::Count(MAX_ADDRESSES)), address_store: address_store_with_cache::new(db), @@ -78,56 +82,68 @@ impl AddressManager { let am = Arc::new(Mutex::new(instance)); let extender = Self::init_local_addresses(&am, tick_service.clone()); - let dyndns_extender = if extender.is_none() && am.lock().config.external_dyndns_host.is_some() { - let res = DynDnsExtender::new(am.lock().config.clone(), am.clone(), tick_service); - if res.is_some() { debug!("[AddrMan] DynDnsExtender constructed"); } else { debug!("[AddrMan] DynDnsExtender NOT constructed (unexpected None)"); } - res + let dyndns_extender = if extender.is_none() { + Self::try_initial_dyndns_resolve(&am, tick_service.clone()) } else { - if extender.is_some() { debug!("[AddrMan] UPnP extender active; DynDNS skipped"); } - else { debug!("[AddrMan] No extender and no DynDNS host configured"); } + debug!("[AddrMan] UPnP extender active; DynDNS skipped"); None }; - // Attempt minimal initial DynDNS resolve (fallback) if applicable - if extender.is_none() { Self::try_initial_dyndns_resolve(&am); } - debug!("[AddrMan] Exit AddressManager::new (upnp_extender={} dyndns_extender={})", extender.is_some(), dyndns_extender.is_some()); + // Note: initial DynDNS seed handled above when applicable + debug!( + "[AddrMan] Exit AddressManager::new (upnp_extender={} dyndns_extender={})", + extender.is_some(), + dyndns_extender.is_some() + ); (am, extender, dyndns_extender) } - fn try_initial_dyndns_resolve(am: &Arc>) { + fn try_initial_dyndns_resolve(am: &Arc>, tick_service: Arc) -> Option { + // Only attempt if a DynDNS host is configured let host_opt = am.lock().config.external_dyndns_host.clone(); - let Some(host) = host_opt else { return }; - let ip_mode = am.lock().config.external_dyndns_ip_version; - let needs = { am.lock().best_local_address().is_none() }; - if !needs { return; } + let Some(host) = host_opt else { return None }; + let ip_mode = am.lock().config.external_dyndns_ip_version; + let mut initial_ip: Option = None; + debug!("[AddrMan] Performing minimal initial DynDNS resolve for host {}", host); // DNS resolution outside lock let result: std::io::Result> = (|| { - use std::net::ToSocketAddrs; (host.as_str(), 0).to_socket_addrs().map(|it| it.map(|sa| sa.ip()).collect()) + use std::net::ToSocketAddrs; + (host.as_str(), 0).to_socket_addrs().map(|it| it.map(|sa| sa.ip()).collect()) })(); match result { Ok(mut ips) => { ips.retain(|ip| IpAddress::new(*ip).is_publicly_routable()); - // Selection respecting IpVersionMode (mirrors DynDnsExtender logic) let selected = match ip_mode { IpVersionMode::Ipv4 => ips.iter().cloned().find(|ip| matches!(ip, std::net::IpAddr::V4(_))), IpVersionMode::Ipv6 => ips.iter().cloned().find(|ip| matches!(ip, std::net::IpAddr::V6(_))), IpVersionMode::Auto => { - if let Some(v4) = ips.iter().cloned().find(|ip| matches!(ip, std::net::IpAddr::V4(_))) { Some(v4) } else { ips.get(0).cloned() } + if let Some(v4) = ips.iter().cloned().find(|ip| matches!(ip, std::net::IpAddr::V4(_))) { + Some(v4) + } else { + ips.get(0).cloned() + } } }; if let Some(ip) = selected { + initial_ip = Some(ip); let mut guard = am.lock(); - if guard.best_local_address().is_none() { - let port = guard.config.default_p2p_port(); - guard.set_best_local_address(NetAddress::new(ip.into(), port)); - debug!("[AddrMan] Initial DynDNS external IP set to {}:{}", ip, port); - } + let port = guard.config.default_p2p_port(); + guard.set_best_local_address(NetAddress::new(ip.into(), port)); + debug!("[AddrMan] Initial DynDNS external IP set to {}:{}", ip, port); } else { debug!("[AddrMan] Initial DynDNS resolve returned no public addresses"); } } Err(e) => debug!("[AddrMan] Initial DynDNS resolve failed: {e}"), } + + let extender = DynDnsExtender::new(am.lock().config.clone(), am.clone(), tick_service, initial_ip); + if extender.is_some() { + debug!("[AddrMan] DynDnsExtender constructed"); + } else { + debug!("[AddrMan] DynDnsExtender NOT constructed (unexpected None)"); + } + extender } pub fn register_external_ip_change_sink(&mut self, sink: Arc) { @@ -211,24 +227,24 @@ impl AddressManager { return Left(Right(iter::empty())); }; // TODO: Add Check IPv4 or IPv6 match from Go code - Right(network_interfaces - .into_iter() - .map(|(_, ip)| IpAddress::from(ip)) - .filter(|ip| { - if self.config.disable_ipv6_interface_discovery { - // Skip IPv6 during automatic discovery if the flag is set - !matches!(**ip, std::net::IpAddr::V6(_)) - } else { - true - } - }) - .filter(|&ip| ip.is_publicly_routable()) - .map( - |ip| { - info!("Publicly routable local address found: {}", ip); - NetAddress::new(ip, self.config.default_p2p_port()) - }, - )) + Right( + network_interfaces + .into_iter() + .map(|(_, ip)| IpAddress::from(ip)) + .filter(|ip| { + if self.config.disable_ipv6_interface_discovery { + // Skip IPv6 during automatic discovery if the flag is set + !matches!(**ip, std::net::IpAddr::V6(_)) + } else { + true + } + }) + .filter(|&ip| ip.is_publicly_routable()) + .map(|ip| { + info!("Publicly routable local address found: {}", ip); + NetAddress::new(ip, self.config.default_p2p_port()) + }), + ) } else { Left(Right(iter::empty())) } From e25ab2cbb70dc788f9a9b18ea2637ef2041115ef Mon Sep 17 00:00:00 2001 From: p4bpj Date: Sun, 24 Aug 2025 20:00:20 +0200 Subject: [PATCH 7/7] Update staggered outbound reconnect delay from 30s to 15s --- components/connectionmanager/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/connectionmanager/src/lib.rs b/components/connectionmanager/src/lib.rs index f741a776b1..b96e417794 100644 --- a/components/connectionmanager/src/lib.rs +++ b/components/connectionmanager/src/lib.rs @@ -93,7 +93,7 @@ impl ConnectionManager { }); } - /// Synchronously trigger a staggered outbound reconnect (terminates peers one by one with 30s delays) + /// Synchronously trigger a staggered outbound reconnect (terminates peers one by one with 15s delays) pub fn trigger_outbound_reconnect(&self) { let outbound_peers: Vec<_> = self.p2p_adaptor.active_peers() .into_iter() @@ -106,7 +106,7 @@ impl ConnectionManager { } let peer_count = outbound_peers.len(); - info!("Starting staggered outbound reconnect: {} peers will be renewed with 30s delays", peer_count); + info!("Starting staggered outbound reconnect: {} peers will be renewed with 15s delays", peer_count); // Spawn async task for staggered renewal let p2p_adaptor = self.p2p_adaptor.clone(); @@ -122,8 +122,8 @@ impl ConnectionManager { if i < peer_count - 1 { force_sender.send(()).unwrap(); - // Wait 30 seconds - tokio::time::sleep(Duration::from_secs(30)).await; + // Wait 15 seconds + tokio::time::sleep(Duration::from_secs(15)).await; } }