commit 24c11779101ea36bc5d82717e67fefd71ff98d38
parent 95a004a668edce897f5e4194a04f1937e3631f4c
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sun, 13 Sep 2026 20:01:29 +0200
feat(p2p): expose peer handshake details
Diffstat:
7 files changed, 156 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -5,6 +5,10 @@ from the Git history and Conventional Commit titles by `deployment.sh`.
## [Unreleased]
+### Added
+
+- expose complete peer handshake details in the P2P interface
+
## [0.4.32] - 2026-09-13
### Fixed
diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs
@@ -382,8 +382,16 @@ pub(super) const INDEX_HTML: &str = concat!(
.peer-status.banned { border-color: #713434; color: #ffb1a8; background: #2a1717; }
.peer-status.error { border-color: #713434; color: #ffb1a8; background: #2a1717; }
.peer-actions { display: flex; gap: 6px; align-items: center; }
+ .peer-details { padding: 4px 7px; font-size: 12px; }
.peer-remove { padding: 4px 7px; border-color: #4f3737; background: #221717; color: #ffb1a8; font-size: 12px; }
.peer-remove:hover { border-color: #ffb1a8; color: #ffd4cf; }
+ .peer-modal-section { display: grid; gap: 8px; margin-top: 14px; }
+ .peer-modal-section h3 { margin: 0; color: #8d989f; font-size: 11px; text-transform: uppercase; }
+ .peer-modal-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 8px; }
+ .peer-modal-field { min-width: 0; display: grid; gap: 5px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; }
+ .peer-modal-field.wide { grid-column: 1 / -1; }
+ .peer-modal-value { min-width: 0; overflow-wrap: anywhere; }
+ .peer-capabilities { display: flex; flex-wrap: wrap; gap: 6px; }
.network-health { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(0, 1.2fr); gap: 12px; align-items: stretch; margin-bottom: 12px; }
.network-health-state { display: grid; align-content: center; gap: 5px; border: 1px solid #3a4248; border-radius: 8px; padding: 12px; background: #111316; }
.network-health-state.healthy { border-color: #566d25; background: #182112; }
@@ -1040,7 +1048,7 @@ pub(super) const INDEX_HTML: &str = concat!(
<td data-label="Sent" x-show="developmentMode()" x-text="peer.messages_sent"></td>
<td data-label="Received" x-show="developmentMode()" x-text="peer.messages_received"></td>
<td data-label="Last error" x-show="developmentMode()" x-text="peer.last_error || ''"></td>
- <td data-label="Actions"><div class="peer-actions"><button class="peer-remove" type="button" x-show="canRemovePeer(peer)" @click="removePeer(peer)">Remove</button><span class="muted" x-show="!canRemovePeer(peer)">Observed</span></div></td>
+ <td data-label="Actions"><div class="peer-actions"><button class="peer-details" type="button" @click="openPeerModal(peer)">Details</button><button class="peer-remove" type="button" x-show="canRemovePeer(peer)" @click="removePeer(peer)">Remove</button><span class="muted" x-show="!canRemovePeer(peer)">Observed</span></div></td>
</tr>
</template>
<tr class="skeleton-card" x-show="peerPage.loading" aria-hidden="true">
@@ -1538,6 +1546,79 @@ pub(super) const INDEX_HTML: &str = concat!(
<p class="muted">This recovery option removes only the local blockchain. Your wallet and settings stay on this device.</p>
</section>
</div>
+ <div class="setup-overlay transaction-overlay" x-show="selectedPeerAddress" x-transition.opacity @click.self="closePeerModal()" role="dialog" aria-modal="true" aria-labelledby="peer-details-title">
+ <section class="tx-modal">
+ <div class="tx-modal-head">
+ <div class="tx-modal-title">
+ <span class="peer-status" :class="peerDetail() ? peerStatus(peerDetail()) : 'pending'" x-text="peerDetail() ? peerStatusLabel(peerDetail()) : 'Unavailable'"></span>
+ <h2 id="peer-details-title">Peer details</h2>
+ <code x-text="peerDetail()?.address || selectedPeerAddress || '-'"></code>
+ </div>
+ <button type="button" @click="closePeerModal">Close</button>
+ </div>
+
+ <div class="tx-modal-empty" x-show="!peerDetail()">This peer is no longer present in the current peer list.</div>
+ <template x-if="peerDetail()">
+ <div>
+ <section class="peer-modal-section">
+ <h3>Connection</h3>
+ <div class="peer-modal-grid">
+ <div class="peer-modal-field"><span class="tx-label">Direction</span><span class="peer-modal-value" x-text="peerDetail().direction"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Last contact</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_contact_ms)"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Last success</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_success_ms)"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Messages sent</span><span class="peer-modal-value" x-text="peerDetail().messages_sent ?? 0"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Messages received</span><span class="peer-modal-value" x-text="peerDetail().messages_received ?? 0"></span></div>
+ </div>
+ </section>
+
+ <section class="peer-modal-section">
+ <h3>Current chain view</h3>
+ <div class="peer-modal-grid">
+ <div class="peer-modal-field"><span class="tx-label">Height</span><span class="peer-modal-value" x-text="peerDetail().last_known_height ?? '-'"></span></div>
+ <div class="peer-modal-field wide"><span class="tx-label">Tip hash</span><code class="peer-modal-value" x-text="peerDetail().last_known_tip_hash || '-'"></code></div>
+ </div>
+ </section>
+
+ <section class="peer-modal-section">
+ <h3>Last protocol hello</h3>
+ <div class="muted">These values are advertised by the remote peer during its handshake.</div>
+ <div class="peer-modal-grid">
+ <div class="peer-modal-field"><span class="tx-label">Protocol version</span><span class="peer-modal-value" x-text="peerDetail().last_hello?.protocol_version ?? '-'"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Network</span><code class="peer-modal-value" x-text="peerDetail().last_hello?.network_id || '-'"></code></div>
+ <div class="peer-modal-field"><span class="tx-label">Advertised address</span><code class="peer-modal-value" x-text="peerDetail().last_hello?.listen_addr || '-'"></code></div>
+ <div class="peer-modal-field"><span class="tx-label">Node ID</span><code class="peer-modal-value" x-text="peerDetail().last_hello?.node_id || '-'"></code></div>
+ <div class="peer-modal-field"><span class="tx-label">Hello height</span><span class="peer-modal-value" x-text="peerDetail().last_hello?.height ?? '-'"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Peer time</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_hello?.time_ms)"></span></div>
+ <div class="peer-modal-field wide"><span class="tx-label">Genesis hash</span><code class="peer-modal-value" x-text="peerDetail().last_hello?.genesis_hash || '-'"></code></div>
+ <div class="peer-modal-field wide"><span class="tx-label">Hello tip hash</span><code class="peer-modal-value" x-text="peerDetail().last_hello?.tip_hash || '-'"></code></div>
+ <div class="peer-modal-field wide">
+ <span class="tx-label">Capabilities</span>
+ <div class="peer-capabilities" x-show="peerCapabilities(peerDetail()).length">
+ <template x-for="capability in peerCapabilities(peerDetail())" :key="capability"><code class="pill" x-text="capability"></code></template>
+ </div>
+ <span class="muted" x-show="peerCapabilities(peerDetail()).length === 0">None advertised</span>
+ </div>
+ </div>
+ </section>
+
+ <section class="peer-modal-section">
+ <h3>Health and enforcement</h3>
+ <div class="peer-modal-grid">
+ <div class="peer-modal-field"><span class="tx-label">Clock offset</span><span class="peer-modal-value" x-text="peerClockLabel(peerDetail())"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Clock accepted</span><span class="peer-modal-value" x-text="peerDetail().last_clock_offset_accepted == null ? '-' : (peerDetail().last_clock_offset_accepted ? 'Yes' : 'No')"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Clock observed</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_clock_observed_ms)"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Misbehavior score</span><span class="peer-modal-value" x-text="peerDetail().misbehavior_score ?? 0"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Banned for</span><span class="peer-modal-value" x-text="peerBanLabel(peerDetail())"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Banned until</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().banned_until_ms)"></span></div>
+ <div class="peer-modal-field"><span class="tx-label">Last error time</span><span class="peer-modal-value" x-text="peerTimestampLabel(peerDetail().last_error_ms)"></span></div>
+ <div class="peer-modal-field wide"><span class="tx-label">Last error</span><span class="peer-modal-value" x-text="peerDetail().last_error || '-'"></span></div>
+ <div class="peer-modal-field wide"><span class="tx-label">Ban reason</span><span class="peer-modal-value" x-text="peerDetail().ban_reason || '-'"></span></div>
+ </div>
+ </section>
+ </div>
+ </template>
+ </section>
+ </div>
<div class="setup-overlay transaction-overlay" x-show="sendConfirmModalOpen" x-transition.opacity @click.self="closeSendConfirmModal()" role="dialog" aria-modal="true" aria-labelledby="send-confirm-title">
<section class="tx-modal address-book-modal">
<div class="tx-modal-head">
diff --git a/src/adapters/p2p/handshake.rs b/src/adapters/p2p/handshake.rs
@@ -59,6 +59,24 @@ pub(super) async fn record_peer_status(
}
}
+async fn record_peer_hello(
+ network: &GossipNetwork,
+ known_peer: &Option<String>,
+ remote_addr: SocketAddr,
+ hello: ProtocolHello,
+) {
+ let (peer, direction) = match known_peer {
+ Some(peer) => (peer.clone(), PeerDirection::Outbound),
+ None => (remote_addr.to_string(), PeerDirection::Inbound),
+ };
+ network
+ .inner
+ .peers
+ .lock()
+ .await
+ .record_hello(&peer, direction, hello);
+}
+
pub(super) async fn process_hello(
network: &GossipNetwork,
remote_addr: SocketAddr,
@@ -182,6 +200,7 @@ async fn process_hello_inner(
&PeerStatus::with_time(hello.height, hello.tip_hash.clone(), hello.time_ms),
)
.await;
+ record_peer_hello(network, known_peer, remote_addr, hello.clone()).await;
let mut status = if request_bootstrap {
PeerStatus::with_bootstrap_request(hello.height, hello.tip_hash, hello.time_ms)
} else {
diff --git a/src/adapters/p2p/tests.rs b/src/adapters/p2p/tests.rs
@@ -1114,6 +1114,7 @@ async fn hello_records_remote_clock_observation() {
tip_hash: "tip".to_string(),
time_ms: remote_time_ms,
};
+ let expected_hello = hello.clone();
let mut known_peer = None;
super::process_hello(
@@ -1132,6 +1133,7 @@ async fn hello_records_remote_clock_observation() {
.unwrap();
assert!(peer.last_clock_offset_ms.unwrap() > 30_000);
assert_eq!(peer.last_clock_offset_accepted, Some(true));
+ assert_eq!(peer.last_hello.as_ref(), Some(&expected_hello));
}
#[tokio::test]
diff --git a/src/app/peer_book.rs b/src/app/peer_book.rs
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use super::{
PEER_CLOCK_OFFSET_ACCEPTANCE_MS, PEER_CLOCK_OFFSET_STALE_MS, PEER_MISBEHAVIOR_BAN_MS,
- PEER_MISBEHAVIOR_BAN_SCORE, now_ms,
+ PEER_MISBEHAVIOR_BAN_SCORE, ProtocolHello, now_ms,
};
pub const MAX_DISCOVERED_PEERS: usize = 256;
@@ -212,6 +212,9 @@ impl PeerBook {
.last_known_tip_hash
.clone()
.or(from_peer.last_known_tip_hash);
+ if to_peer.last_hello.is_none() {
+ to_peer.last_hello = from_peer.last_hello;
+ }
if from_peer.last_clock_observed_ms > to_peer.last_clock_observed_ms {
to_peer.last_clock_offset_ms = from_peer.last_clock_offset_ms;
to_peer.last_clock_offset_accepted = from_peer.last_clock_offset_accepted;
@@ -357,6 +360,10 @@ impl PeerBook {
}
}
+ pub fn record_hello(&mut self, address: &str, direction: PeerDirection, hello: ProtocolHello) {
+ self.ensure(address, direction).last_hello = Some(hello);
+ }
+
pub fn record_clock_observation(
&mut self,
address: &str,
@@ -498,6 +505,8 @@ pub struct PeerInfo {
pub messages_received: u64,
pub last_known_height: Option<u64>,
pub last_known_tip_hash: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub last_hello: Option<ProtocolHello>,
#[serde(default)]
pub last_clock_offset_ms: Option<i64>,
#[serde(default)]
@@ -522,6 +531,7 @@ impl PeerInfo {
messages_received: 0,
last_known_height: None,
last_known_tip_hash: None,
+ last_hello: None,
last_clock_offset_ms: None,
last_clock_offset_accepted: None,
last_clock_observed_ms: None,
diff --git a/src/main_tests.rs b/src/main_tests.rs
@@ -126,6 +126,20 @@ fn management_ui_opens_on_the_dashboard_and_exposes_chain_health_thresholds() {
}
#[test]
+fn management_ui_exposes_complete_peer_details() {
+ let html = include_str!("adapters/http/index_html.rs");
+ let javascript = include_str!("../www/assets/iuna-ui.js");
+ assert!(html.contains("@click=\"openPeerModal(peer)\""));
+ assert!(html.contains("Last protocol hello"));
+ assert!(html.contains("peerDetail().last_hello?.protocol_version"));
+ assert!(html.contains("peerCapabilities(peerDetail())"));
+ assert!(html.contains("Health and enforcement"));
+ assert!(javascript.contains("selectedPeerAddress: null"));
+ assert!(javascript.contains("peerDetail()"));
+ assert!(javascript.contains("closePeerModal()"));
+}
+
+#[test]
fn management_ui_uses_an_in_app_transfer_confirmation_and_marks_fee_errors() {
let html = include_str!("adapters/http/index_html.rs");
let javascript = include_str!("../www/assets/iuna-ui.js");
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -19,6 +19,7 @@ window.iunaApp = function iunaApp() {
walletUtxos: [],
mempool: [],
peers: [],
+ selectedPeerAddress: null,
p2pMetrics: {},
blockchainMetrics: { enabled: false, latest: null, charts: [] },
loadingMetrics: false,
@@ -1415,6 +1416,7 @@ window.iunaApp = function iunaApp() {
this.closeBurnLeaderRanksModal();
this.closeBurnBundleModal();
this.closeChainResetModal();
+ this.closePeerModal();
},
async loadOlderBlocks() {
@@ -2809,11 +2811,33 @@ window.iunaApp = function iunaApp() {
async removePeer(peer) {
try {
await this.postForm("/api/peers", { peer: peer.address }, `Removed peer ${peer.address}`, "DELETE");
+ if (this.selectedPeerAddress === peer.address) this.closePeerModal();
} catch (error) {
this.showFlash(error.message, "error");
}
},
+ openPeerModal(peer) {
+ this.selectedPeerAddress = peer?.address || null;
+ },
+
+ closePeerModal() {
+ this.selectedPeerAddress = null;
+ },
+
+ peerDetail() {
+ return this.peers.find((peer) => peer.address === this.selectedPeerAddress) || null;
+ },
+
+ peerCapabilities(peer) {
+ return Array.isArray(peer?.last_hello?.capabilities) ? peer.last_hello.capabilities : [];
+ },
+
+ peerTimestampLabel(timestampMs) {
+ if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || timestampMs <= 0) return "-";
+ return `${new Date(timestampMs).toLocaleString()} (${this.relativeTimeLabel(timestampMs)})`;
+ },
+
addressBookEntries() {
return Object.entries(this.addressBook || {})
.map(([address, name]) => ({ address, name }))