iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 40a1500136eed2904d0fe8d667e8d5d7a2b5715e
parent 78dbeb6834127582cba25bed52e9aed2332919b1
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 31 Jul 2026 15:32:39 +0200

Make inbound P2P explicitly public

Diffstat:
Msrc/adapters/config_store.rs | 39+++++++++++++++++++++++++++++++++++++++
Msrc/adapters/http.rs | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/adapters/p2p.rs | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/main.rs | 22+++++++++++++++++-----
Mwww/assets/iuna-ui.js | 35+++++++++++++++++++++++++++++++----
5 files changed, 285 insertions(+), 43 deletions(-)

diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -27,6 +27,7 @@ pub struct UiConfig { pub burn_fee: Amount, pub pow_mine_fee: Amount, pub keep_track_of_metrics: bool, + pub p2p_accept_inbound: bool, pub p2p_announce_addr: Option<String>, pub peers: Vec<String>, } @@ -42,6 +43,7 @@ impl Default for UiConfig { burn_fee: DEFAULT_BURN_FEE, pow_mine_fee: MINE_FINALIZER_FEE, keep_track_of_metrics: false, + p2p_accept_inbound: false, p2p_announce_addr: None, peers: Vec::new(), } @@ -69,6 +71,8 @@ struct ConfigFile { #[serde(default)] keep_track_of_metrics: bool, #[serde(default)] + p2p_accept_inbound: Option<bool>, + #[serde(default)] p2p_announce_addr: Option<String>, #[serde(default)] peers: Vec<String>, @@ -101,6 +105,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> { burn_fee: Some(config.burn_fee), pow_mine_fee: Some(config.pow_mine_fee), keep_track_of_metrics: config.keep_track_of_metrics, + p2p_accept_inbound: Some(config.p2p_accept_inbound), p2p_announce_addr: config.p2p_announce_addr.clone(), peers: config.peers.clone(), }; @@ -136,6 +141,10 @@ fn load(path: &Path) -> Result<UiConfig> { MICRO_IUNA }; + let p2p_accept_inbound = stored + .p2p_accept_inbound + .unwrap_or_else(|| stored.p2p_announce_addr.is_some()); + Ok(UiConfig { setup_complete: stored.setup_complete, auth_password_hash: stored.auth_password_hash, @@ -151,6 +160,7 @@ fn load(path: &Path) -> Result<UiConfig> { .map(|fee| fee.saturating_mul(scale)) .unwrap_or(MINE_FINALIZER_FEE), keep_track_of_metrics: stored.keep_track_of_metrics, + p2p_accept_inbound, p2p_announce_addr: stored.p2p_announce_addr, peers: stored.peers, }) @@ -195,6 +205,7 @@ mod tests { assert!(stored.contains("\"burn_fee\": 1")); assert!(stored.contains("\"pow_mine_fee\": 1000000")); assert!(stored.contains("\"keep_track_of_metrics\": false")); + assert!(stored.contains("\"p2p_accept_inbound\": false")); assert!(stored.contains("\"p2p_announce_addr\": null")); assert!(stored.contains("\"peers\": []")); } @@ -215,6 +226,7 @@ mod tests { burn_fee: 3 * MICRO_IUNA, pow_mine_fee: 2 * MICRO_IUNA, keep_track_of_metrics: true, + p2p_accept_inbound: true, p2p_announce_addr: Some("203.0.113.10:9444".to_string()), peers: vec!["127.0.0.1:9444".to_string()], }, @@ -230,6 +242,7 @@ mod tests { assert_eq!(config.burn_fee, 3 * MICRO_IUNA); assert_eq!(config.pow_mine_fee, 2 * MICRO_IUNA); assert!(config.keep_track_of_metrics); + assert!(config.p2p_accept_inbound); assert_eq!( config.p2p_announce_addr.as_deref(), Some("203.0.113.10:9444") @@ -273,10 +286,36 @@ mod tests { assert_eq!(config.burn_fee, DEFAULT_BURN_FEE); assert_eq!(config.pow_mine_fee, MINE_FINALIZER_FEE); assert!(!config.keep_track_of_metrics); + assert!(!config.p2p_accept_inbound); assert_eq!(config.peers, vec!["127.0.0.1:9444"]); } #[test] + fn legacy_config_with_announce_address_keeps_public_node_enabled() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.json"); + fs::write( + &path, + r#"{ + "version": 1, + "setup_complete": true, + "p2p_announce_addr": "203.0.113.10:9444", + "peers": [] +} +"#, + ) + .unwrap(); + + let config = load_or_create(&path).unwrap(); + + assert!(config.p2p_accept_inbound); + assert_eq!( + config.p2p_announce_addr.as_deref(), + Some("203.0.113.10:9444") + ); + } + + #[test] fn loads_old_config_with_burn_rate_as_mining_enabled() { let dir = tempdir().unwrap(); let path = dir.path().join("config.json"); diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -149,6 +149,11 @@ struct P2pAnnounceForm { } #[derive(Debug, Deserialize)] +struct P2pInboundForm { + enabled: bool, +} + +#[derive(Debug, Deserialize)] struct TransferForm { to: String, amount: Amount, @@ -414,6 +419,7 @@ pub async fn serve( ) .route("/api/settings/pow-mining", post(api_pow_mining_form)) .route("/api/settings/metrics", post(api_metrics_settings_form)) + .route("/api/settings/p2p-inbound", post(api_p2p_inbound_form)) .route("/api/settings/p2p-announce", post(api_p2p_announce_form)) .route("/api/transfer", post(api_transfer_form)) .route("/settings/burn-per-block", post(burn_per_block_form)) @@ -885,6 +891,13 @@ async fn api_p2p_announce_form( action_json(set_p2p_announce_addr(&state, form.addr).await) } +async fn api_p2p_inbound_form( + State(state): State<HttpState>, + Form(form): Form<P2pInboundForm>, +) -> Json<ActionResponse> { + action_json(set_p2p_accept_inbound(&state, form.enabled).await) +} + async fn burn_per_block_form( State(state): State<HttpState>, Form(form): Form<BurnSettingsForm>, @@ -1045,6 +1058,29 @@ async fn set_p2p_announce_addr(state: &HttpState, addr: String) -> Result<()> { Ok(()) } +async fn set_p2p_accept_inbound(state: &HttpState, enabled: bool) -> Result<()> { + let previous = state.gossip.accepts_inbound().await; + if enabled { + state.gossip.set_accept_inbound(true).await?; + } + + let mut config = state.ui_config.lock().await; + let mut next_config = config.clone(); + next_config.p2p_accept_inbound = enabled; + if let Err(error) = config_store::save(&state.config_path, &next_config) { + let _ = state.gossip.set_accept_inbound(previous).await; + return Err(error); + } + *config = next_config; + drop(config); + + if !enabled { + state.gossip.set_accept_inbound(false).await?; + } + + Ok(()) +} + async fn replace_metrics_for_snapshot( store: &SqliteChainStore, snapshot: crate::domain::ChainSnapshot, @@ -2451,6 +2487,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .settings-mode-copy { min-width: 0; display: grid; gap: 4px; } .settings-mode-title { color: #e8edf0; font-size: 15px; font-weight: 850; } .settings-form { display: grid; gap: 10px; align-items: stretch; } + .public-p2p-form { margin-top: 14px; } .settings-form label, .settings-form input { width: 100%; } .metrics-shell { display: grid; gap: 12px; } .metrics-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; } @@ -2927,7 +2964,7 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="peer-toolbar"> <div> <h2>Peers</h2> - <div class="panel-description">Manage outbound peers and inspect which nodes are healthy, lagging, or failing. Inbound rows are observed sessions and are not persisted in config.</div> + <div class="panel-description" x-text="p2pAcceptInbound ? 'Manage outbound peers and inspect inbound or outbound sessions. Public node is accepting inbound P2P connections.' : 'Manage outbound peers and inspect sync health. This node is outbound-only and does not open an inbound P2P port.'"></div> </div> <form class="peer-form" @submit.prevent="addPeer"> <label>Peer address<input x-model="peerAddress" placeholder="seed.example:9444"></label> @@ -3209,8 +3246,20 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="panel" x-show="advancedMode()"> <h3>Node Networking</h3> - <form class="settings-form" @submit.prevent="saveP2pAnnounce"> + <div class="settings-mode-row"> + <div class="settings-mode-copy"> + <div class="settings-mode-title">Public node</div> + <div class="muted" x-text="p2pAcceptInbound ? 'Accepting inbound P2P connections.' : 'Outbound-only P2P; no inbound port is open.'"></div> + </div> + <label class="toggle-switch" :class="{ active: p2pAcceptInbound }"> + <input type="checkbox" :checked="p2pAcceptInbound" @change="setP2pAcceptInbound($event.target.checked)"> + <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> + <span class="toggle-text" x-text="p2pAcceptInbound ? 'Public' : 'Private'"></span> + </label> + </div> + <form class="settings-form public-p2p-form" x-show="p2pAcceptInbound" x-transition @submit.prevent="saveP2pAnnounce"> <label>Public P2P address<input x-model="p2pAnnounceAddr" @input="p2pAnnounceDirty = true" placeholder="203.0.113.10:9444"></label> + <div class="muted">Use this only when TCP port 9444 is reachable from the internet.</div> <div class="setup-actions"><button class="primary" type="submit">Save</button></div> </form> </div> @@ -3852,7 +3901,7 @@ mod tests { } #[tokio::test] - async fn p2p_announce_setting_persists_config_and_updates_gossip() { + async fn p2p_announce_setting_persists_config_and_waits_for_public_node() { let dir = tempfile::tempdir().unwrap(); let config_path = dir.path().join("config.json"); let state = auth_test_state( @@ -3875,11 +3924,27 @@ mod tests { ); match state.gossip.peer_exchange().await { GossipEnvelope::PeerList { peers } => { + assert!(!peers.contains(&"203.0.113.10:9444".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + + super::set_p2p_accept_inbound(&state, true).await.unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(config.p2p_accept_inbound); + assert!(state.gossip.accepts_inbound().await); + match state.gossip.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { assert!(peers.contains(&"203.0.113.10:9444".to_string())); } other => panic!("expected peer list, got {other:?}"), } + super::set_p2p_accept_inbound(&state, false).await.unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(!config.p2p_accept_inbound); + assert!(!state.gossip.accepts_inbound().await); + super::set_p2p_announce_addr(&state, " ".to_string()) .await .unwrap(); diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -19,6 +19,7 @@ use tokio::{ tcp::{OwnedReadHalf, OwnedWriteHalf}, }, sync::{Mutex, mpsc}, + task::JoinHandle, time::{Instant, interval, interval_at, sleep, timeout}, }; @@ -143,6 +144,7 @@ struct GossipNetworkInner { listen_addr: SocketAddr, p2p_announce_addr: Mutex<Option<SocketAddr>>, node_id: String, + accept_task: Mutex<Option<JoinHandle<()>>>, sessions: Mutex<BTreeMap<String, mpsc::Sender<OutboundBatch>>>, tx_delivery: Mutex<BTreeMap<String, PeerTransactionDelivery>>, inbound_limiter: Arc<StdMutex<InboundConnectionLimiter>>, @@ -437,6 +439,7 @@ impl GossipNetwork { listen_addr: "127.0.0.1:0".parse().unwrap(), p2p_announce_addr: Mutex::new(None), node_id: new_node_id(), + accept_task: Mutex::new(None), sessions: Mutex::new(BTreeMap::new()), tx_delivery: Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), @@ -450,10 +453,8 @@ impl GossipNetwork { peers: SharedPeerBook, addr: SocketAddr, p2p_announce_addr: Option<SocketAddr>, + accept_inbound: bool, ) -> Result<Self> { - let listener = TcpListener::bind(addr) - .await - .with_context(|| format!("binding p2p listener on {addr}"))?; let network = Self { inner: Arc::new(GossipNetworkInner { node, @@ -461,6 +462,7 @@ impl GossipNetwork { listen_addr: addr, p2p_announce_addr: Mutex::new(p2p_announce_addr), node_id: new_node_id(), + accept_task: Mutex::new(None), sessions: Mutex::new(BTreeMap::new()), tx_delivery: Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), @@ -468,25 +470,59 @@ impl GossipNetwork { }), }; - tokio::spawn(accept_loop(network.clone(), listener)); + if accept_inbound { + network.set_accept_inbound(true).await?; + } tokio::spawn(outbound_supervisor(network.clone())); network.ensure_outbound_sessions().await; Ok(network) } + pub async fn set_accept_inbound(&self, enabled: bool) -> Result<()> { + let mut accept_task = self.inner.accept_task.lock().await; + if enabled { + if accept_task.is_some() { + return Ok(()); + } + let listener = TcpListener::bind(self.inner.listen_addr) + .await + .with_context(|| format!("binding p2p listener on {}", self.inner.listen_addr))?; + *accept_task = Some(tokio::spawn(accept_loop(self.clone(), listener))); + } else if let Some(task) = accept_task.take() { + task.abort(); + } + Ok(()) + } + + pub async fn accepts_inbound(&self) -> bool { + self.inner.accept_task.lock().await.is_some() + } + pub async fn set_p2p_announce_addr(&self, addr: Option<SocketAddr>) { *self.inner.p2p_announce_addr.lock().await = addr; } - async fn advertised_addr(&self) -> SocketAddr { - (*self.inner.p2p_announce_addr.lock().await).unwrap_or(self.inner.listen_addr) + async fn advertised_addr(&self) -> Option<SocketAddr> { + if !self.accepts_inbound().await { + return None; + } + Some((*self.inner.p2p_announce_addr.lock().await).unwrap_or(self.inner.listen_addr)) + } + + async fn self_filter_addr(&self) -> Option<SocketAddr> { + if let Some(addr) = *self.inner.p2p_announce_addr.lock().await { + return Some(addr); + } + self.accepts_inbound() + .await + .then_some(self.inner.listen_addr) } async fn is_self_peer(&self, address: &str) -> bool { is_self_peer_address_for( address, self.inner.listen_addr, - self.advertised_addr().await, + self.self_filter_addr().await, ) } @@ -708,20 +744,21 @@ impl GossipNetwork { pub async fn peer_exchange(&self) -> GossipEnvelope { let advertised_addr = self.advertised_addr().await; - let self_addr = advertised_addr.to_string(); + let self_filter_addr = self.self_filter_addr().await; + let self_addr = advertised_addr.map(|addr| addr.to_string()); let peers = self .inner .peers .lock() .await - .addresses_except(&self_addr) + .addresses_except(self_addr.as_deref().unwrap_or("")) .into_iter() - .filter(|peer| !is_self_peer_address_for(peer, self.inner.listen_addr, advertised_addr)) + .filter(|peer| { + !is_self_peer_address_for(peer, self.inner.listen_addr, self_filter_addr) + }) .collect::<Vec<_>>(); GossipEnvelope::PeerList { - peers: std::iter::once(self_addr) - .chain(peers.into_iter()) - .collect(), + peers: self_addr.into_iter().chain(peers.into_iter()).collect(), } } @@ -733,18 +770,18 @@ impl GossipNetwork { .await .connectable_addresses_at(crate::app::now_ms()); let address_set = addresses.iter().cloned().collect::<BTreeSet<_>>(); - let advertised_addr = self.advertised_addr().await; + let self_filter_addr = self.self_filter_addr().await; let mut sessions = self.inner.sessions.lock().await; sessions.retain(|peer, _| { let keep = address_set.contains(peer) - && !is_self_peer_address_for(peer, self.inner.listen_addr, advertised_addr); + && !is_self_peer_address_for(peer, self.inner.listen_addr, self_filter_addr); if !keep { P2pMetricsCounters::inc(&self.inner.metrics.self_peer_skips); } keep }); for peer in addresses { - if is_self_peer_address_for(&peer, self.inner.listen_addr, advertised_addr) { + if is_self_peer_address_for(&peer, self.inner.listen_addr, self_filter_addr) { P2pMetricsCounters::inc(&self.inner.metrics.self_peer_skips); continue; } @@ -845,9 +882,9 @@ async fn outbound_session( ) { let mut reconnect_delay = INITIAL_RECONNECT_DELAY; loop { - let advertised_addr = network.advertised_addr().await; + let self_filter_addr = network.self_filter_addr().await; if !peer_is_configured_outbound(&network, &peer).await - || is_self_peer_address_for(&peer, network.inner.listen_addr, advertised_addr) + || is_self_peer_address_for(&peer, network.inner.listen_addr, self_filter_addr) { network.inner.sessions.lock().await.remove(&peer); return; @@ -959,7 +996,7 @@ async fn session_loop( .unwrap_or_else(|| format!("inbound {remote_addr}")); let advertised_addr = network.advertised_addr().await; let hello = network.inner.node.lock().await.hello( - Some(advertised_addr.to_string()), + advertised_addr.map(|addr| addr.to_string()), Some(network.inner.node_id.clone()), ); write_envelope(&mut writer, &hello).await?; @@ -1550,11 +1587,11 @@ async fn apply_peer_list( remote_addr: SocketAddr, peers: Vec<String>, ) -> Result<()> { - let advertised_addr = network.advertised_addr().await; + let self_filter_addr = network.self_filter_addr().await; let mut peerbook = network.inner.peers.lock().await; for address in peers { let peer = normalize_advertised_peer(&address, remote_addr)?; - if is_self_peer_address_for(&peer, network.inner.listen_addr, advertised_addr) { + if is_self_peer_address_for(&peer, network.inner.listen_addr, self_filter_addr) { P2pMetricsCounters::inc(&network.inner.metrics.self_peer_skips); } else if peer_list_address_is_discoverable(&peer, remote_addr)? { peerbook.add_peer(peer); @@ -2795,11 +2832,11 @@ fn ip_is_publicly_discoverable(ip: IpAddr) -> bool { fn is_self_peer_address_for( address: &str, listen_addr: SocketAddr, - advertised_addr: SocketAddr, + advertised_addr: Option<SocketAddr>, ) -> bool { address.parse::<SocketAddr>().is_ok_and(|candidate| { is_self_socket_addr(candidate, listen_addr) - || is_self_socket_addr(candidate, advertised_addr) + || advertised_addr.is_some_and(|addr| is_self_socket_addr(candidate, addr)) }) } @@ -2896,22 +2933,22 @@ mod tests { assert!(super::is_self_peer_address_for( "127.0.0.1:9545", listen_addr, - listen_addr + Some(listen_addr) )); assert!(super::is_self_peer_address_for( "0.0.0.0:9545", listen_addr, - listen_addr + Some(listen_addr) )); assert!(!super::is_self_peer_address_for( "127.0.0.1:9546", listen_addr, - listen_addr + Some(listen_addr) )); assert!(!super::is_self_peer_address_for( "203.0.113.10:9545", listen_addr, - listen_addr + Some(listen_addr) )); } @@ -3233,9 +3270,10 @@ mod tests { inner: Arc::new(super::GossipNetworkInner { node: Arc::clone(&node), peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), - listen_addr: "127.0.0.1:9544".parse().unwrap(), - p2p_announce_addr: tokio::sync::Mutex::new(None), + listen_addr: "127.0.0.1:0".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(Some("127.0.0.1:9544".parse().unwrap())), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -3299,6 +3337,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -3346,6 +3385,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -3398,6 +3438,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -3443,6 +3484,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -3658,6 +3700,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -3765,6 +3808,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -3824,6 +3868,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -3896,6 +3941,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -3968,6 +4014,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4018,6 +4065,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4102,6 +4150,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4153,6 +4202,7 @@ mod tests { listen_addr: "0.0.0.0:9444".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4200,6 +4250,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4259,6 +4310,39 @@ mod tests { } #[tokio::test] + async fn peer_exchange_does_not_advertise_self_when_outbound_only() { + let alice = Wallet::from_seed("px-private-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "127.0.0.1:9545".to_string(), + ]))); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node, + peers, + listen_addr: "127.0.0.1:9544".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), + node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), + sessions: tokio::sync::Mutex::new(BTreeMap::new()), + tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new( + StdMutex::new(super::InboundConnectionLimiter::default()), + ), + metrics: super::P2pMetricsCounters::default(), + }), + }; + match network.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { + assert!(!peers.contains(&"127.0.0.1:9544".to_string())); + assert!(peers.contains(&"127.0.0.1:9545".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + } + + #[tokio::test] async fn peer_exchange_advertises_stable_listen_and_known_peers() { let alice = Wallet::from_seed("px-alice"); let allocations = allocations(std::slice::from_ref(&alice), 1_000); @@ -4273,6 +4357,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4281,6 +4366,7 @@ mod tests { metrics: super::P2pMetricsCounters::default(), }), }; + network.set_accept_inbound(true).await.unwrap(); match network.peer_exchange().await { GossipEnvelope::PeerList { peers } => { @@ -4289,6 +4375,7 @@ mod tests { } other => panic!("expected peer list, got {other:?}"), } + network.set_accept_inbound(false).await.unwrap(); } #[tokio::test] @@ -4304,9 +4391,10 @@ mod tests { inner: Arc::new(super::GossipNetworkInner { node, peers, - listen_addr: "0.0.0.0:9444".parse().unwrap(), + listen_addr: "127.0.0.1:0".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(Some("8.8.8.8:9444".parse().unwrap())), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4315,6 +4403,7 @@ mod tests { metrics: super::P2pMetricsCounters::default(), }), }; + network.set_accept_inbound(true).await.unwrap(); match network.peer_exchange().await { GossipEnvelope::PeerList { peers } => { @@ -4326,6 +4415,7 @@ mod tests { } other => panic!("expected peer list, got {other:?}"), } + network.set_accept_inbound(false).await.unwrap(); } #[tokio::test] @@ -4341,6 +4431,7 @@ mod tests { listen_addr: "127.0.0.1:9544".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4349,7 +4440,6 @@ mod tests { metrics: super::P2pMetricsCounters::default(), }), }; - super::apply_peer_list( &network, "127.0.0.1:9545".parse().unwrap(), @@ -4376,6 +4466,7 @@ mod tests { listen_addr: "0.0.0.0:9444".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(Some("8.8.8.8:9444".parse().unwrap())), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4396,6 +4487,7 @@ mod tests { let addresses = peers.lock().await.addresses(); assert!(!addresses.contains(&"8.8.8.8:9444".to_string())); assert!(addresses.contains(&"8.8.4.4:9445".to_string())); + network.set_accept_inbound(false).await.unwrap(); } #[tokio::test] @@ -4411,6 +4503,7 @@ mod tests { listen_addr: "0.0.0.0:9444".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4449,6 +4542,7 @@ mod tests { listen_addr: "0.0.0.0:9444".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4488,6 +4582,7 @@ mod tests { listen_addr: "0.0.0.0:9444".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4517,6 +4612,7 @@ mod tests { listen_addr: "0.0.0.0:9545".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4553,6 +4649,7 @@ mod tests { listen_addr: "0.0.0.0:9545".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4610,6 +4707,7 @@ mod tests { listen_addr: "0.0.0.0:9444".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( @@ -4666,6 +4764,7 @@ mod tests { listen_addr: "0.0.0.0:9444".parse().unwrap(), p2p_announce_addr: tokio::sync::Mutex::new(None), node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( diff --git a/src/main.rs b/src/main.rs @@ -49,6 +49,11 @@ async fn main() -> Result<()> { } let mut ui_config = config_store::load_or_create(&config_path)?; let p2p_announce_addr = configured_p2p_announce_addr(&opts, &ui_config)?; + let p2p_accept_inbound = opts.p2p_announce_addr.is_some() || ui_config.p2p_accept_inbound; + if let Some(addr) = opts.p2p_announce_addr { + ui_config.p2p_accept_inbound = true; + ui_config.p2p_announce_addr = Some(addr.to_string()); + } let advertised_p2p_addr = p2p_announce_addr.unwrap_or(opts.p2p_addr); let wallet_load = load_startup_wallet(&wallet_path)?; let wallet_address = wallet_load.address().to_string(); @@ -106,9 +111,15 @@ async fn main() -> Result<()> { println!("config file: {}", config_path.display()); println!("chain database: {}", chain_store.path().display()); println!("management UI: http://{}", opts.http_addr); - println!("p2p listener: {}", opts.p2p_addr); - if let Some(addr) = p2p_announce_addr { - println!("p2p announce address: {addr}"); + if p2p_accept_inbound { + println!("p2p listener: {}", opts.p2p_addr); + } else { + println!("p2p listener: disabled (outbound-only)"); + } + if p2p_accept_inbound { + if let Some(addr) = p2p_announce_addr { + println!("p2p announce address: {addr}"); + } } println!( "automatic finalization: VDF-driven, burning {} IUNA per block with {} IUNA per byte fee rate", @@ -121,6 +132,7 @@ async fn main() -> Result<()> { Arc::clone(&peers), opts.p2p_addr, p2p_announce_addr, + p2p_accept_inbound, ) .await?; let mut stratum_status = StratumStatus { @@ -455,8 +467,8 @@ fn help_text() -> &'static str { --wallet <path> Wallet file (default <data-dir>/wallet.json)\n\ --chain-db <path> Chain SQLite database (default <data-dir>/chain.sqlite3)\n\ --http <addr:port> HTTP management UI address (default 127.0.0.1:18661)\n\ - --p2p <addr:port> P2P TCP listener address (default 127.0.0.1:9444)\n\ - --p2p-announce <addr:port> Public P2P address to gossip instead of --p2p\n\ + --p2p <addr:port> Inbound P2P listener address when public node is enabled\n\ + --p2p-announce <addr:port> Public P2P address to gossip; enables inbound P2P\n\ --stratum <addr:port> Stratum V1 listener for SHA-256 ASIC miners\n\ --join <addr:port> Fetch chain snapshot from this peer before finalization\n\ --data-dir <path> Local wallet directory (default ~/.iuna)\n\ diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -47,6 +47,7 @@ window.iunaApp = function iunaApp() { settingsPasswordConfirm: "", settingsFeedback: null, keepTrackOfMetrics: false, + p2pAcceptInbound: false, p2pAnnounceAddr: "", p2pAnnounceDirty: false, setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false, requires_peer: false }, @@ -322,6 +323,15 @@ window.iunaApp = function iunaApp() { async refreshConfig() { this.config = await this.fetchJson("/api/config"); + this.syncConfigState(); + }, + + syncConfigState() { + this.keepTrackOfMetrics = this.config.keep_track_of_metrics === true; + this.p2pAcceptInbound = this.config.p2p_accept_inbound === true; + if (!this.p2pAnnounceDirty) { + this.p2pAnnounceAddr = this.config.p2p_announce_addr || ""; + } }, async refreshWalletSetup() { @@ -510,10 +520,7 @@ window.iunaApp = function iunaApp() { this.fetchJson("/api/network/health"), ]); this.config = config; - this.keepTrackOfMetrics = config.keep_track_of_metrics === true; - if (!this.p2pAnnounceDirty) { - this.p2pAnnounceAddr = config.p2p_announce_addr || ""; - } + this.syncConfigState(); if (!this.allowedTabs().includes(this.tab)) { this.setTab("wallet"); } @@ -935,7 +942,27 @@ window.iunaApp = function iunaApp() { } }, + async setP2pAcceptInbound(enabled) { + const previous = this.p2pAcceptInbound; + try { + this.p2pAcceptInbound = enabled; + await this.postForm( + "/api/settings/p2p-inbound", + { enabled }, + enabled ? "Public node enabled" : "Switched to outbound-only P2P" + ); + await this.refreshConfig(); + } catch (error) { + this.p2pAcceptInbound = previous; + this.showFlash(error.message, "error"); + } + }, + async saveP2pAnnounce() { + if (!this.p2pAcceptInbound) { + this.showFlash("Enable public node before setting a public P2P address", "error"); + return; + } const addr = this.p2pAnnounceAddr.trim(); try { await this.postForm(