commit 81221c7a43784310cb6219653e573363cca8f5be
parent 67e32a8fc0b5ecf0b5b8e28000cc58d8aeb2f6ee
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 31 Jul 2026 09:47:45 +0200
Add configurable P2P announce address
Diffstat:
5 files changed, 391 insertions(+), 32 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_announce_addr: Option<String>,
pub peers: Vec<String>,
}
@@ -41,6 +42,7 @@ impl Default for UiConfig {
burn_fee: DEFAULT_BURN_FEE,
pow_mine_fee: MINE_FINALIZER_FEE,
keep_track_of_metrics: false,
+ p2p_announce_addr: None,
peers: Vec::new(),
}
}
@@ -67,6 +69,8 @@ struct ConfigFile {
#[serde(default)]
keep_track_of_metrics: bool,
#[serde(default)]
+ p2p_announce_addr: Option<String>,
+ #[serde(default)]
peers: Vec<String>,
}
@@ -97,6 +101,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_announce_addr: config.p2p_announce_addr.clone(),
peers: config.peers.clone(),
};
let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?;
@@ -146,6 +151,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_announce_addr: stored.p2p_announce_addr,
peers: stored.peers,
})
}
@@ -189,6 +195,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_announce_addr\": null"));
assert!(stored.contains("\"peers\": []"));
}
@@ -208,6 +215,7 @@ mod tests {
burn_fee: 3 * MICRO_IUNA,
pow_mine_fee: 2 * MICRO_IUNA,
keep_track_of_metrics: true,
+ p2p_announce_addr: Some("203.0.113.10:9444".to_string()),
peers: vec!["127.0.0.1:9444".to_string()],
},
)
@@ -222,6 +230,10 @@ 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_eq!(
+ config.p2p_announce_addr.as_deref(),
+ Some("203.0.113.10:9444")
+ );
assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
}
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -144,6 +144,11 @@ struct MetricsSettingsForm {
}
#[derive(Debug, Deserialize)]
+struct P2pAnnounceForm {
+ addr: String,
+}
+
+#[derive(Debug, Deserialize)]
struct TransferForm {
to: String,
amount: Amount,
@@ -409,6 +414,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-announce", post(api_p2p_announce_form))
.route("/api/transfer", post(api_transfer_form))
.route("/settings/burn-per-block", post(burn_per_block_form))
.route("/transfer", post(transfer_form))
@@ -872,6 +878,13 @@ async fn api_metrics_settings_form(
action_json(set_keep_track_of_metrics(&state, form.enabled).await)
}
+async fn api_p2p_announce_form(
+ State(state): State<HttpState>,
+ Form(form): Form<P2pAnnounceForm>,
+) -> Json<ActionResponse> {
+ action_json(set_p2p_announce_addr(&state, form.addr).await)
+}
+
async fn burn_per_block_form(
State(state): State<HttpState>,
Form(form): Form<BurnSettingsForm>,
@@ -1010,6 +1023,28 @@ async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool) -> Result<(
config_store::save(&state.config_path, &config)
}
+async fn set_p2p_announce_addr(state: &HttpState, addr: String) -> Result<()> {
+ let trimmed = addr.trim();
+ let parsed = if trimmed.is_empty() {
+ None
+ } else {
+ Some(
+ trimmed
+ .parse::<SocketAddr>()
+ .with_context(|| format!("invalid P2P announce address {trimmed}"))?,
+ )
+ };
+
+ let mut config = state.ui_config.lock().await;
+ let mut next_config = config.clone();
+ next_config.p2p_announce_addr = parsed.map(|addr| addr.to_string());
+ config_store::save(&state.config_path, &next_config)?;
+ *config = next_config;
+ drop(config);
+ state.gossip.set_p2p_announce_addr(parsed).await;
+ Ok(())
+}
+
async fn replace_metrics_for_snapshot(
store: &SqliteChainStore,
snapshot: crate::domain::ChainSnapshot,
@@ -3157,6 +3192,13 @@ const INDEX_HTML: &str = r#"<!doctype html>
</label>
</div>
</div>
+ <div class="panel" x-show="advancedMode()">
+ <h3>Node Networking</h3>
+ <form class="settings-form" @submit.prevent="saveP2pAnnounce">
+ <label>Public P2P address<input x-model="p2pAnnounceAddr" @input="p2pAnnounceDirty = true" placeholder="203.0.113.10:9444"></label>
+ <div class="setup-actions"><button class="primary" type="submit">Save</button></div>
+ </form>
+ </div>
<div class="panel">
<h3>Change Password</h3>
<div class="setup-feedback" :class="settingsFeedback?.kind" x-show="settingsFeedback" x-transition x-text="settingsFeedback?.message"></div>
@@ -3407,7 +3449,7 @@ mod tests {
chain_store::SqliteChainStore, config_store, config_store::UiConfig,
p2p::GossipNetwork, wallet_store,
},
- app::{NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus},
+ app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus},
domain::{
Block, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, Transaction,
Wallet,
@@ -3795,6 +3837,54 @@ mod tests {
}
#[tokio::test]
+ async fn p2p_announce_setting_persists_config_and_updates_gossip() {
+ let dir = tempfile::tempdir().unwrap();
+ let config_path = dir.path().join("config.json");
+ let state = auth_test_state(
+ config_path.clone(),
+ UiConfig {
+ setup_complete: true,
+ ..UiConfig::default()
+ },
+ )
+ .await;
+
+ super::set_p2p_announce_addr(&state, " 203.0.113.10:9444 ".to_string())
+ .await
+ .unwrap();
+
+ let config = config_store::load_or_create(&config_path).unwrap();
+ assert_eq!(
+ config.p2p_announce_addr.as_deref(),
+ Some("203.0.113.10:9444")
+ );
+ 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_announce_addr(&state, " ".to_string())
+ .await
+ .unwrap();
+ let config = config_store::load_or_create(&config_path).unwrap();
+ assert!(config.p2p_announce_addr.is_none());
+ }
+
+ #[tokio::test]
+ async fn p2p_announce_setting_rejects_invalid_address() {
+ let dir = tempfile::tempdir().unwrap();
+ let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await;
+
+ let error = super::set_p2p_announce_addr(&state, "not-an-address".to_string())
+ .await
+ .unwrap_err();
+
+ assert!(format!("{error:#}").contains("invalid P2P announce address"));
+ }
+
+ #[tokio::test]
async fn setup_config_form_can_add_bootstrap_peer() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.json");
diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs
@@ -139,6 +139,7 @@ struct GossipNetworkInner {
node: SharedNode,
peers: SharedPeerBook,
listen_addr: SocketAddr,
+ p2p_announce_addr: Mutex<Option<SocketAddr>>,
node_id: String,
sessions: Mutex<BTreeMap<String, mpsc::Sender<OutboundBatch>>>,
tx_delivery: Mutex<BTreeMap<String, PeerTransactionDelivery>>,
@@ -426,6 +427,7 @@ impl GossipNetwork {
node,
peers,
listen_addr: "127.0.0.1:0".parse().unwrap(),
+ p2p_announce_addr: Mutex::new(None),
node_id: new_node_id(),
sessions: Mutex::new(BTreeMap::new()),
tx_delivery: Mutex::new(BTreeMap::new()),
@@ -435,7 +437,12 @@ impl GossipNetwork {
}
}
- pub async fn start(node: SharedNode, peers: SharedPeerBook, addr: SocketAddr) -> Result<Self> {
+ pub async fn start(
+ node: SharedNode,
+ peers: SharedPeerBook,
+ addr: SocketAddr,
+ p2p_announce_addr: Option<SocketAddr>,
+ ) -> Result<Self> {
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("binding p2p listener on {addr}"))?;
@@ -444,6 +451,7 @@ impl GossipNetwork {
node,
peers,
listen_addr: addr,
+ p2p_announce_addr: Mutex::new(p2p_announce_addr),
node_id: new_node_id(),
sessions: Mutex::new(BTreeMap::new()),
tx_delivery: Mutex::new(BTreeMap::new()),
@@ -458,6 +466,22 @@ impl GossipNetwork {
Ok(network)
}
+ 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 is_self_peer(&self, address: &str) -> bool {
+ is_self_peer_address_for(
+ address,
+ self.inner.listen_addr,
+ self.advertised_addr().await,
+ )
+ }
+
pub fn metrics(&self) -> P2pMetrics {
let mut metrics = self.inner.metrics.snapshot();
metrics.transaction_ack_pending = self
@@ -675,7 +699,8 @@ impl GossipNetwork {
}
pub async fn peer_exchange(&self) -> GossipEnvelope {
- let self_addr = self.inner.listen_addr.to_string();
+ let advertised_addr = self.advertised_addr().await;
+ let self_addr = advertised_addr.to_string();
let peers = self
.inner
.peers
@@ -683,7 +708,7 @@ impl GossipNetwork {
.await
.addresses_except(&self_addr)
.into_iter()
- .filter(|peer| !is_self_peer_address(peer, self.inner.listen_addr))
+ .filter(|peer| !is_self_peer_address_for(peer, self.inner.listen_addr, advertised_addr))
.collect::<Vec<_>>();
GossipEnvelope::PeerList {
peers: std::iter::once(self_addr)
@@ -700,17 +725,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 mut sessions = self.inner.sessions.lock().await;
sessions.retain(|peer, _| {
- let keep =
- address_set.contains(peer) && !is_self_peer_address(peer, self.inner.listen_addr);
+ let keep = address_set.contains(peer)
+ && !is_self_peer_address_for(peer, self.inner.listen_addr, advertised_addr);
if !keep {
P2pMetricsCounters::inc(&self.inner.metrics.self_peer_skips);
}
keep
});
for peer in addresses {
- if is_self_peer_address(&peer, self.inner.listen_addr) {
+ if is_self_peer_address_for(&peer, self.inner.listen_addr, advertised_addr) {
P2pMetricsCounters::inc(&self.inner.metrics.self_peer_skips);
continue;
}
@@ -811,8 +837,9 @@ async fn outbound_session(
) {
let mut reconnect_delay = INITIAL_RECONNECT_DELAY;
loop {
+ let advertised_addr = network.advertised_addr().await;
if !peer_is_configured_outbound(&network, &peer).await
- || is_self_peer_address(&peer, network.inner.listen_addr)
+ || is_self_peer_address_for(&peer, network.inner.listen_addr, advertised_addr)
{
network.inner.sessions.lock().await.remove(&peer);
return;
@@ -922,8 +949,9 @@ async fn session_loop(
.as_ref()
.map(|peer| format!("outbound {peer}"))
.unwrap_or_else(|| format!("inbound {remote_addr}"));
+ let advertised_addr = network.advertised_addr().await;
let hello = network.inner.node.lock().await.hello(
- Some(network.inner.listen_addr.to_string()),
+ Some(advertised_addr.to_string()),
Some(network.inner.node_id.clone()),
);
write_envelope(&mut writer, &hello).await?;
@@ -1152,7 +1180,7 @@ async fn process_envelope(
}
GossipEnvelope::PeerAnnouncement { address } => {
let peer = normalize_advertised_peer(&address, remote_addr)?;
- if is_self_peer_address(&peer, network.inner.listen_addr) {
+ if network.is_self_peer(&peer).await {
P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections);
forget_stale_self_peer(network, known_peer).await;
} else if remember_discoverable_advertised_peer(
@@ -1497,10 +1525,11 @@ async fn apply_peer_list(
remote_addr: SocketAddr,
peers: Vec<String>,
) -> Result<()> {
+ let advertised_addr = network.advertised_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(&peer, network.inner.listen_addr) {
+ if is_self_peer_address_for(&peer, network.inner.listen_addr, advertised_addr) {
P2pMetricsCounters::inc(&network.inner.metrics.self_peer_skips);
} else if peer_list_address_is_discoverable(&peer, remote_addr)? {
peerbook.add_peer(peer);
@@ -2200,7 +2229,7 @@ async fn process_hello(
if let Some(listen_addr) = &hello.listen_addr {
let peer = normalize_advertised_peer(listen_addr, remote_addr)?;
- if is_self_peer_address(&peer, network.inner.listen_addr) {
+ if network.is_self_peer(&peer).await {
P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections);
forget_stale_self_peer(network, known_peer).await;
} else {
@@ -2403,10 +2432,15 @@ fn ip_is_publicly_discoverable(ip: IpAddr) -> bool {
}
}
-fn is_self_peer_address(address: &str, listen_addr: SocketAddr) -> bool {
- address
- .parse::<SocketAddr>()
- .is_ok_and(|candidate| is_self_socket_addr(candidate, listen_addr))
+fn is_self_peer_address_for(
+ address: &str,
+ listen_addr: SocketAddr,
+ advertised_addr: SocketAddr,
+) -> bool {
+ address.parse::<SocketAddr>().is_ok_and(|candidate| {
+ is_self_socket_addr(candidate, listen_addr)
+ || is_self_socket_addr(candidate, advertised_addr)
+ })
}
fn is_self_socket_addr(candidate: SocketAddr, listen_addr: SocketAddr) -> bool {
@@ -2499,11 +2533,24 @@ mod tests {
fn loopback_peer_on_unspecified_listen_port_is_self() {
let listen_addr: SocketAddr = "0.0.0.0:9545".parse().unwrap();
- assert!(super::is_self_peer_address("127.0.0.1:9545", listen_addr));
- assert!(super::is_self_peer_address("0.0.0.0:9545", listen_addr));
- assert!(!super::is_self_peer_address("127.0.0.1:9546", listen_addr));
- assert!(!super::is_self_peer_address(
+ assert!(super::is_self_peer_address_for(
+ "127.0.0.1:9545",
+ listen_addr,
+ listen_addr
+ ));
+ assert!(super::is_self_peer_address_for(
+ "0.0.0.0:9545",
+ listen_addr,
+ listen_addr
+ ));
+ assert!(!super::is_self_peer_address_for(
+ "127.0.0.1:9546",
+ listen_addr,
+ listen_addr
+ ));
+ assert!(!super::is_self_peer_address_for(
"203.0.113.10:9545",
+ listen_addr,
listen_addr
));
}
@@ -2826,6 +2873,7 @@ mod tests {
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),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -2888,6 +2936,7 @@ mod tests {
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),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -2934,6 +2983,7 @@ mod tests {
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),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -2985,6 +3035,7 @@ mod tests {
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),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3029,6 +3080,7 @@ mod tests {
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),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3243,6 +3295,7 @@ mod tests {
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),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3349,6 +3402,7 @@ mod tests {
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),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3412,6 +3466,7 @@ mod tests {
"iuna.jhx.app:9444".to_string(),
]))),
listen_addr: "127.0.0.1:9544".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3495,6 +3550,7 @@ mod tests {
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),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3545,6 +3601,7 @@ mod tests {
node: Arc::clone(&node),
peers: Arc::clone(&peers),
listen_addr: "0.0.0.0:9444".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3591,6 +3648,7 @@ mod tests {
node,
peers: Arc::clone(&peers),
listen_addr: "127.0.0.1:9544".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3663,6 +3721,7 @@ mod tests {
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(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3683,6 +3742,43 @@ mod tests {
}
#[tokio::test]
+ async fn peer_exchange_filters_announced_self_from_known_peers() {
+ let alice = Wallet::from_seed("px-announced-self-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![
+ "8.8.8.8:9444".to_string(),
+ "8.8.4.4:9444".to_string(),
+ ])));
+ let network = super::GossipNetwork {
+ inner: Arc::new(super::GossipNetworkInner {
+ node,
+ peers,
+ 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(),
+ 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_eq!(
+ peers.iter().filter(|peer| *peer == "8.8.8.8:9444").count(),
+ 1
+ );
+ assert!(peers.contains(&"8.8.4.4:9444".to_string()));
+ }
+ other => panic!("expected peer list, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
async fn peer_list_adds_stable_outbound_peers() {
let alice = Wallet::from_seed("px-recv-alice");
let allocations = allocations(std::slice::from_ref(&alice), 1_000);
@@ -3693,6 +3789,7 @@ mod tests {
node,
peers: Arc::clone(&peers),
listen_addr: "127.0.0.1:9544".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3717,6 +3814,41 @@ mod tests {
}
#[tokio::test]
+ async fn peer_list_ignores_announced_self_address() {
+ let alice = Wallet::from_seed("px-list-announced-self-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::default()));
+ let network = super::GossipNetwork {
+ inner: Arc::new(super::GossipNetworkInner {
+ node,
+ peers: Arc::clone(&peers),
+ 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(),
+ 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(),
+ }),
+ };
+
+ super::apply_peer_list(
+ &network,
+ "8.8.4.4:9444".parse().unwrap(),
+ vec!["8.8.8.8:9444".to_string(), "8.8.4.4:9445".to_string()],
+ )
+ .await
+ .unwrap();
+
+ 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()));
+ }
+
+ #[tokio::test]
async fn peer_list_ignores_private_ephemeral_addresses() {
let alice = Wallet::from_seed("px-private-ephemeral-alice");
let allocations = allocations(std::slice::from_ref(&alice), 1_000);
@@ -3727,6 +3859,7 @@ mod tests {
node,
peers: Arc::clone(&peers),
listen_addr: "0.0.0.0:9444".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3764,6 +3897,7 @@ mod tests {
node,
peers: Arc::clone(&peers),
listen_addr: "0.0.0.0:9444".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3802,6 +3936,7 @@ mod tests {
node,
peers: Arc::clone(&peers),
listen_addr: "0.0.0.0:9444".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3830,6 +3965,7 @@ mod tests {
node,
peers: Arc::clone(&peers),
listen_addr: "0.0.0.0:9545".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3865,6 +4001,7 @@ mod tests {
node,
peers: Arc::clone(&peers),
listen_addr: "0.0.0.0:9545".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3921,6 +4058,7 @@ mod tests {
node,
peers: Arc::clone(&peers),
listen_addr: "0.0.0.0:9444".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
@@ -3976,6 +4114,7 @@ mod tests {
node,
peers: Arc::clone(&peers),
listen_addr: "0.0.0.0:9444".parse().unwrap(),
+ p2p_announce_addr: tokio::sync::Mutex::new(None),
node_id: super::new_node_id(),
sessions: tokio::sync::Mutex::new(BTreeMap::new()),
tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()),
diff --git a/src/main.rs b/src/main.rs
@@ -48,6 +48,8 @@ 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 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();
if opts.chain_mode == ChainMode::Genesis {
@@ -58,7 +60,8 @@ async fn main() -> Result<()> {
ui_config.burn_fee = GENESIS_INITIAL_BURN_FEE;
config_store::save(&config_path, &ui_config)?;
}
- let ledger = initialize_ledger(&opts, &wallet_address, &chain_store).await?;
+ let ledger =
+ initialize_ledger(&opts, &wallet_address, &chain_store, advertised_p2p_addr).await?;
let has_chain = opts.has_chain() || persisted_chain_exists;
let initial_burn_per_block = initial_burn_per_block(&opts, &ui_config);
let initial_burn_fee = initial_burn_fee(&opts, &ui_config);
@@ -104,14 +107,22 @@ async fn main() -> Result<()> {
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}");
+ }
println!(
"automatic finalization: VDF-driven, burning {} IUNA per block with {} IUNA per byte fee rate",
format_iuna(initial_burn_per_block),
format_iuna(initial_burn_fee)
);
- let gossip =
- p2p::GossipNetwork::start(Arc::clone(&node), Arc::clone(&peers), opts.p2p_addr).await?;
+ let gossip = p2p::GossipNetwork::start(
+ Arc::clone(&node),
+ Arc::clone(&peers),
+ opts.p2p_addr,
+ p2p_announce_addr,
+ )
+ .await?;
let mut stratum_status = StratumStatus {
enabled: false,
listen_addr: None,
@@ -215,6 +226,7 @@ async fn initialize_ledger(
opts: &CliOptions,
wallet_address: &str,
chain_store: &SqliteChainStore,
+ advertised_p2p_addr: SocketAddr,
) -> Result<Ledger> {
if let Some(snapshot) = chain_store.load()? {
if opts.chain_mode == ChainMode::Genesis {
@@ -239,11 +251,28 @@ async fn initialize_ledger(
match opts.chain_mode {
ChainMode::Setup => Ok(setup_ledger()),
ChainMode::Genesis => start_genesis_ledger(wallet_address),
- ChainMode::Join => join_chain_ledger(&opts.join_peers, opts.p2p_addr).await,
+ ChainMode::Join => join_chain_ledger(&opts.join_peers, advertised_p2p_addr).await,
}
}
}
+fn configured_p2p_announce_addr(
+ opts: &CliOptions,
+ ui_config: &config_store::UiConfig,
+) -> Result<Option<SocketAddr>> {
+ if let Some(addr) = opts.p2p_announce_addr {
+ return Ok(Some(addr));
+ }
+ ui_config
+ .p2p_announce_addr
+ .as_deref()
+ .map(|addr| {
+ addr.parse()
+ .with_context(|| format!("invalid configured P2P announce address {addr}"))
+ })
+ .transpose()
+}
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ChainMode {
Setup,
@@ -257,6 +286,7 @@ struct CliOptions {
chain_db_path: Option<PathBuf>,
http_addr: SocketAddr,
p2p_addr: SocketAddr,
+ p2p_announce_addr: Option<SocketAddr>,
stratum_addr: Option<SocketAddr>,
peers: Vec<String>,
join_peers: Vec<String>,
@@ -276,6 +306,7 @@ impl CliOptions {
chain_db_path: None,
http_addr: SocketAddr::from_str("127.0.0.1:18661")?,
p2p_addr: SocketAddr::from_str("127.0.0.1:9444")?,
+ p2p_announce_addr: None,
stratum_addr: None,
peers: Vec::new(),
join_peers: Vec::new(),
@@ -315,6 +346,13 @@ impl CliOptions {
.parse()
.context("invalid --p2p address")?;
}
+ "--p2p-announce" => {
+ opts.p2p_announce_addr = Some(
+ next_value(&mut args, "--p2p-announce")?
+ .parse()
+ .context("invalid --p2p-announce address")?,
+ );
+ }
"--stratum" => {
opts.stratum_addr = Some(
next_value(&mut args, "--stratum")?
@@ -418,6 +456,7 @@ fn help_text() -> &'static str {
--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\
--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\
@@ -718,9 +757,10 @@ mod tests {
use super::{
ChainMode, CliOptions, GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK,
- StartupWallet, extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block,
- initialize_ledger, load_startup_wallet, measure_vdf_rounds, persist_chain_snapshot,
- run_chain_persistence_with_interval, validate_wallet_for_mode,
+ StartupWallet, configured_p2p_announce_addr, extrapolate_vdf_rounds, help_text,
+ initial_burn_fee, initial_burn_per_block, initialize_ledger, load_startup_wallet,
+ measure_vdf_rounds, persist_chain_snapshot, run_chain_persistence_with_interval,
+ validate_wallet_for_mode,
};
fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> {
@@ -930,6 +970,64 @@ mod tests {
}
#[test]
+ fn p2p_announce_port_can_be_configured() {
+ let opts = parse(&["--p2p-announce", "203.0.113.10:9444"])
+ .unwrap()
+ .unwrap();
+
+ assert_eq!(
+ opts.p2p_announce_addr,
+ Some("203.0.113.10:9444".parse().unwrap())
+ );
+ }
+
+ #[test]
+ fn configured_p2p_announce_addr_uses_cli_before_config() {
+ let opts = parse(&["--p2p-announce", "203.0.113.20:9444"])
+ .unwrap()
+ .unwrap();
+ let config = UiConfig {
+ p2p_announce_addr: Some("203.0.113.10:9444".to_string()),
+ ..UiConfig::default()
+ };
+
+ assert_eq!(
+ configured_p2p_announce_addr(&opts, &config).unwrap(),
+ Some("203.0.113.20:9444".parse().unwrap())
+ );
+ }
+
+ #[test]
+ fn configured_p2p_announce_addr_reads_config_without_cli() {
+ let opts = parse(&[]).unwrap().unwrap();
+ let config = UiConfig {
+ p2p_announce_addr: Some("203.0.113.10:9444".to_string()),
+ ..UiConfig::default()
+ };
+
+ assert_eq!(
+ configured_p2p_announce_addr(&opts, &config).unwrap(),
+ Some("203.0.113.10:9444".parse().unwrap())
+ );
+ }
+
+ #[test]
+ fn configured_p2p_announce_addr_rejects_invalid_config() {
+ let opts = parse(&[]).unwrap().unwrap();
+ let config = UiConfig {
+ p2p_announce_addr: Some("not-an-address".to_string()),
+ ..UiConfig::default()
+ };
+
+ let error = configured_p2p_announce_addr(&opts, &config).unwrap_err();
+ assert!(
+ error
+ .to_string()
+ .contains("invalid configured P2P announce address")
+ );
+ }
+
+ #[test]
fn http_management_port_defaults_to_iuna_port() {
let opts = parse(&[]).unwrap().unwrap();
assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18661");
@@ -1055,7 +1153,7 @@ mod tests {
.unwrap()
.unwrap();
- let error = initialize_ledger(&opts, fresh_wallet.address(), &store)
+ let error = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr)
.await
.unwrap_err();
@@ -1078,7 +1176,7 @@ mod tests {
.unwrap()
.unwrap();
- let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store)
+ let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr)
.await
.unwrap();
@@ -1115,7 +1213,7 @@ mod tests {
.unwrap()
.unwrap();
- let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store)
+ let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr)
.await
.unwrap();
@@ -1144,7 +1242,7 @@ mod tests {
.unwrap()
.unwrap();
- let resumed = initialize_ledger(&opts, bob.address(), &store)
+ let resumed = initialize_ledger(&opts, bob.address(), &store, opts.p2p_addr)
.await
.unwrap();
@@ -1172,7 +1270,7 @@ VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0)
.unwrap()
.unwrap();
- let error = initialize_ledger(&opts, wallet.address(), &store)
+ let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr)
.await
.unwrap_err();
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -37,6 +37,8 @@ window.iunaApp = function iunaApp() {
settingsPasswordConfirm: "",
settingsFeedback: null,
keepTrackOfMetrics: false,
+ p2pAnnounceAddr: "",
+ p2pAnnounceDirty: false,
setupWallet: { address: null, seed_phrase: null, dev_verify_bypass: false, requires_peer: false },
setupWalletMode: "create",
setupSeedStep: "write",
@@ -499,6 +501,9 @@ window.iunaApp = function iunaApp() {
]);
this.config = config;
this.keepTrackOfMetrics = config.keep_track_of_metrics === true;
+ if (!this.p2pAnnounceDirty) {
+ this.p2pAnnounceAddr = config.p2p_announce_addr || "";
+ }
if (!this.allowedTabs().includes(this.tab)) {
this.setTab("wallet");
}
@@ -920,6 +925,21 @@ window.iunaApp = function iunaApp() {
}
},
+ async saveP2pAnnounce() {
+ const addr = this.p2pAnnounceAddr.trim();
+ try {
+ await this.postForm(
+ "/api/settings/p2p-announce",
+ { addr },
+ addr ? "P2P announce address saved" : "P2P announce address cleared"
+ );
+ this.p2pAnnounceAddr = addr;
+ this.p2pAnnounceDirty = false;
+ } catch (error) {
+ this.showFlash(error.message, "error");
+ }
+ },
+
automaticBurnFeeDraft() {
return this.parseiunaAmount(this.burnFeeDraft);
},