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:
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(