From bc6507db0f31d2187e3b789d63e5ef3b94d4b880 Mon Sep 17 00:00:00 2001 From: Joost Jager Date: Wed, 1 Apr 2026 19:18:13 +0200 Subject: [PATCH] Allow binding to port 0 for OS-assigned ports Add support for configuring listening addresses with port 0, letting the OS pick a free port. After binding, the actual port is resolved via local_addr() and stored in last_bound_addresses on Node, preserved across restarts so the node rebinds the same ports. Node::listening_addresses() returns the last bound addresses when available, falling back to configured addresses. The gossip broadcast task and announcement_addresses() prefer actual bound addresses over configured ones, so OS-assigned ports are correctly announced. Port 0 is only allowed under cfg(test). In production, the builder rejects it to prevent accidentally announcing ephemeral ports. Tests now use 127.0.0.1:0 instead of a deterministic port picker, eliminating potential port collisions between concurrent test runs. AI tools were used in preparing this commit. --- src/builder.rs | 7 +++++ src/config.rs | 11 +++++++ src/lib.rs | 55 +++++++++++++++++++++++++++++---- tests/common/mod.rs | 13 +------- tests/integration_tests_rust.rs | 27 ++++++++-------- 5 files changed, 82 insertions(+), 31 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index cd8cc184f..9e55fbed6 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -541,6 +541,11 @@ impl NodeBuilder { return Err(BuildError::InvalidListeningAddresses); } + #[cfg(not(test))] + if listening_addresses.iter().any(crate::config::has_port_zero) { + return Err(BuildError::InvalidListeningAddresses); + } + self.config.listening_addresses = Some(listening_addresses); Ok(self) } @@ -1944,6 +1949,7 @@ fn build_with_store_internal( let (stop_sender, _) = tokio::sync::watch::channel(()); let (background_processor_stop_sender, _) = tokio::sync::watch::channel(()); + let last_bound_addresses: Arc>>> = Arc::new(RwLock::new(None)); let is_running = Arc::new(RwLock::new(false)); let pathfinding_scores_sync_url = pathfinding_scores_sync_config.map(|c| c.url.clone()); @@ -1988,6 +1994,7 @@ fn build_with_store_internal( peer_store, payment_store, lnurl_auth, + last_bound_addresses, is_running, node_metrics, om_mailbox, diff --git a/src/config.rs b/src/config.rs index 71e4d2314..453c658f9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -331,6 +331,17 @@ pub(crate) fn may_announce_channel(config: &Config) -> Result<(), AnnounceError> } } +#[cfg_attr(test, allow(dead_code))] +pub(crate) fn has_port_zero(addr: &SocketAddress) -> bool { + match addr { + SocketAddress::TcpIpV4 { port, .. } + | SocketAddress::TcpIpV6 { port, .. } + | SocketAddress::OnionV3 { port, .. } + | SocketAddress::Hostname { port, .. } => *port == 0, + _ => false, + } +} + pub(crate) fn default_user_config(config: &Config) -> UserConfig { // Initialize the default config values. // diff --git a/src/lib.rs b/src/lib.rs index 2ac4697e8..c39d8ffd6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -233,6 +233,7 @@ pub struct Node { peer_store: Arc>>, payment_store: Arc, lnurl_auth: Arc, + last_bound_addresses: Arc>>>, is_running: Arc>, node_metrics: Arc>, om_mailbox: Option>, @@ -355,14 +356,21 @@ impl Node { ); } - if let Some(listening_addresses) = &self.config.listening_addresses { + let effective_listening_addresses = self + .last_bound_addresses + .read() + .unwrap() + .clone() + .or_else(|| self.config.listening_addresses.clone()); + + if let Some(listening_addresses) = &effective_listening_addresses { // Setup networking let peer_manager_connection_handler = Arc::clone(&self.peer_manager); let listening_logger = Arc::clone(&self.logger); let logger = Arc::clone(&listening_logger); let listening_addrs = listening_addresses.clone(); - let listeners = self.runtime.block_on(async move { + let (listeners, bound_addrs) = self.runtime.block_on(async move { let mut bind_addrs = Vec::with_capacity(listening_addrs.len()); for listening_addr in &listening_addrs { @@ -380,12 +388,29 @@ impl Node { } let mut listeners = Vec::new(); + let mut bound_addrs = Vec::new(); - // Try to bind to all addresses for addr in &bind_addrs { match tokio::net::TcpListener::bind(addr).await { Ok(listener) => { - log_trace!(logger, "Listener bound to {}", addr); + let local_addr = listener.local_addr().map_err(|e| { + log_error!( + logger, + "Failed to retrieve local address from listener: {}", + e + ); + Error::InvalidSocketAddress + })?; + let socket_address = match local_addr { + std::net::SocketAddr::V4(a) => { + SocketAddress::TcpIpV4 { addr: a.ip().octets(), port: a.port() } + }, + std::net::SocketAddr::V6(a) => { + SocketAddress::TcpIpV6 { addr: a.ip().octets(), port: a.port() } + }, + }; + log_info!(logger, "Listening on {}", socket_address); + bound_addrs.push(socket_address); listeners.push(listener); }, Err(e) => { @@ -400,9 +425,11 @@ impl Node { } } - Ok(listeners) + Ok((listeners, bound_addrs)) })?; + *self.last_bound_addresses.write().unwrap() = Some(bound_addrs); + for listener in listeners { let logger = Arc::clone(&listening_logger); let peer_mgr = Arc::clone(&peer_manager_connection_handler); @@ -475,6 +502,7 @@ impl Node { let bcast_cm = Arc::clone(&self.channel_manager); let bcast_pm = Arc::clone(&self.peer_manager); let bcast_config = Arc::clone(&self.config); + let bcast_bound_addrs = Arc::clone(&self.last_bound_addresses); let bcast_store = Arc::clone(&self.kv_store); let bcast_logger = Arc::clone(&self.logger); let bcast_node_metrics = Arc::clone(&self.node_metrics); @@ -525,6 +553,8 @@ impl Node { let addresses = if let Some(announcement_addresses) = bcast_config.announcement_addresses.clone() { announcement_addresses + } else if let Some(bound_addresses) = bcast_bound_addrs.read().unwrap().clone() { + bound_addresses } else if let Some(listening_addresses) = bcast_config.listening_addresses.clone() { listening_addresses } else { @@ -842,15 +872,28 @@ impl Node { } /// Returns our own listening addresses. + /// + /// If the node has been started, this returns the actual bound addresses (which may differ + /// from the configured addresses if port 0 was used). Otherwise, this returns the configured + /// addresses. pub fn listening_addresses(&self) -> Option> { - self.config.listening_addresses.clone() + self.last_bound_addresses + .read() + .unwrap() + .clone() + .or_else(|| self.config.listening_addresses.clone()) } /// Returns the addresses that the node will announce to the network. + /// + /// Returns the configured announcement addresses if set, otherwise falls back to the + /// actual bound addresses (which may differ from configured addresses if port 0 was used), + /// or the configured listening addresses. pub fn announcement_addresses(&self) -> Option> { self.config .announcement_addresses .clone() + .or_else(|| self.last_bound_addresses.read().unwrap().clone()) .or_else(|| self.config.listening_addresses.clone()) } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 4f68f9825..0f06bdc25 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -14,7 +14,6 @@ use std::collections::{HashMap, HashSet}; use std::env; use std::future::Future; use std::path::PathBuf; -use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -269,16 +268,6 @@ pub(crate) fn random_storage_path() -> PathBuf { temp_path } -static NEXT_PORT: AtomicU16 = AtomicU16::new(20000); - -pub(crate) fn generate_listening_addresses() -> Vec { - let port = NEXT_PORT.fetch_add(2, Ordering::Relaxed); - vec![ - SocketAddress::TcpIpV4 { addr: [127, 0, 0, 1], port }, - SocketAddress::TcpIpV4 { addr: [127, 0, 0, 1], port: port + 1 }, - ] -} - pub(crate) fn random_node_alias() -> Option { let mut rng = rng(); let rand_val = rng.random_range(0..1000); @@ -302,7 +291,7 @@ pub(crate) fn random_config(anchor_channels: bool) -> TestConfig { println!("Setting random LDK storage dir: {}", rand_dir.display()); node_config.storage_dir_path = rand_dir.to_str().unwrap().to_owned(); - let listening_addresses = generate_listening_addresses(); + let listening_addresses = vec![SocketAddress::TcpIpV4 { addr: [127, 0, 0, 1], port: 0 }]; println!("Setting LDK listening addresses: {:?}", listening_addresses); node_config.listening_addresses = Some(listening_addresses); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 413b2d44a..4a347e619 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -21,10 +21,10 @@ use common::{ expect_channel_pending_event, expect_channel_ready_event, expect_channel_ready_events, expect_event, expect_payment_claimable_event, expect_payment_received_event, expect_payment_successful_event, expect_splice_pending_event, generate_blocks_and_wait, - generate_listening_addresses, open_channel, open_channel_push_amt, open_channel_with_all, - premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, - setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all, - wait_for_tx, TestChainSource, TestStoreType, TestSyncStore, + open_channel, open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds, + premine_blocks, prepare_rbf, random_chain_source, random_config, setup_bitcoind_and_electrsd, + setup_builder, setup_node, setup_two_nodes, splice_in_with_all, wait_for_tx, TestChainSource, + TestStoreType, TestSyncStore, }; use electrsd::corepc_node::Node as BitcoinD; use electrsd::ElectrsD; @@ -37,6 +37,7 @@ use ldk_node::payment::{ }; use ldk_node::{Builder, Event, NodeError}; use lightning::ln::channelmanager::PaymentId; +use lightning::ln::msgs::SocketAddress; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; use lightning_invoice::{Bolt11InvoiceDescription, Description}; @@ -1424,29 +1425,28 @@ async fn test_node_announcement_propagation() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - // Node A will use both listening and announcement addresses let mut config_a = random_config(true); let node_a_alias_string = "ldk-node-a".to_string(); let mut node_a_alias_bytes = [0u8; 32]; node_a_alias_bytes[..node_a_alias_string.as_bytes().len()] .copy_from_slice(node_a_alias_string.as_bytes()); let node_a_node_alias = Some(NodeAlias(node_a_alias_bytes)); - let node_a_announcement_addresses = generate_listening_addresses(); config_a.node_config.node_alias = node_a_node_alias.clone(); - config_a.node_config.listening_addresses = Some(generate_listening_addresses()); + // Set explicit announcement addresses to verify they take priority over bound addresses. + let node_a_announcement_addresses = vec![ + SocketAddress::TcpIpV4 { addr: [127, 0, 0, 1], port: 10001 }, + SocketAddress::TcpIpV4 { addr: [127, 0, 0, 1], port: 10002 }, + ]; config_a.node_config.announcement_addresses = Some(node_a_announcement_addresses.clone()); - // Node B will only use listening addresses + // Node B uses default config to verify that bound addresses are announced. let mut config_b = random_config(true); let node_b_alias_string = "ldk-node-b".to_string(); let mut node_b_alias_bytes = [0u8; 32]; node_b_alias_bytes[..node_b_alias_string.as_bytes().len()] .copy_from_slice(node_b_alias_string.as_bytes()); let node_b_node_alias = Some(NodeAlias(node_b_alias_bytes)); - let node_b_listening_addresses = generate_listening_addresses(); config_b.node_config.node_alias = node_b_node_alias.clone(); - config_b.node_config.listening_addresses = Some(node_b_listening_addresses.clone()); - config_b.node_config.announcement_addresses = None; let node_a = setup_node(&chain_source, config_a); let node_b = setup_node(&chain_source, config_b); @@ -1505,10 +1505,11 @@ async fn test_node_announcement_propagation() { #[cfg(feature = "uniffi")] assert_eq!(node_b_announcement_info.alias, node_b_alias_string); + let node_b_announcement_addresses = node_b.announcement_addresses().unwrap(); #[cfg(not(feature = "uniffi"))] - assert_eq!(node_b_announcement_info.addresses(), &node_b_listening_addresses); + assert_eq!(node_b_announcement_info.addresses(), &node_b_announcement_addresses); #[cfg(feature = "uniffi")] - assert_eq!(node_b_announcement_info.addresses, node_b_listening_addresses); + assert_eq!(node_b_announcement_info.addresses, node_b_announcement_addresses); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)]