From d322e7d4aa41d94232113cb7680cec1d65a11b0c Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:22 +0100 Subject: access: members refused as a whole With signatures required, a member whose own change broke a rule is refused as a whole: sent nothing, shown in /peers (refused), in stats (sync.refusedPeers) and on the dashboard, until a change of its is taken again or the epoch changes. Auth plan M6 (diverged standing). --- src/api/dashboard/dashboard.html | 13 ++++++++++--- src/api/peers.rs | 25 +++++++++++++++++++++++++ src/api/stats.rs | 8 ++++++++ src/api/stats/prometheus.rs | 6 ++++++ 4 files changed, 49 insertions(+), 3 deletions(-) (limited to 'src/api') diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 601b578..fa4d436 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -284,6 +284,7 @@ #peers td.via { max-width: 0; min-width: 8em; width: 100%; overflow: hidden; text-overflow: ellipsis; } /* A peer's status (not the legend's "dormant"). */ #peers .active { color: var(--ok); } #peers .dormant { color: var(--bad); } + #peers .refused { color: var(--bad); font-weight: 600; } #peers .unknown { color: var(--muted); } .tree { font-family: ui-monospace, monospace; font-size: 12px; } /* Every entry indented alike: a branch's triangle sits in the indentation, left of @@ -658,9 +659,15 @@ function renderPeers() { el("td", { title: id + (peer.neighbour ? "" : "\nnot a neighbour: this node only answers it, and syncs with it on its own only as a neighbour") }, peer.alias || el("span", { class: "id" }, short(id))), - // How long dormant is about how long unseen ("seen"): in the tooltip only. - el("td", { class: peer.status, title: peer.status === "dormant" - ? "dormant for " + age(aged("dormantForSecs", peer.dormantForSecs)) : "" }, peer.status), + // How long dormant is about how long unseen ("seen"): in the tooltip only. Refused + // as a whole (its own change broke the swarm's rules) above all. + peer.refused + ? el("td", { class: "refused", title: "refused as a whole for " + + age(aged("secsAgo", peer.refused.secsAgo)) + ": its change " + + short(peer.refused.change) + " broke the swarm's rules (" + peer.refused.why + + "), so this node sends it nothing" }, "refused") + : el("td", { class: peer.status, title: peer.status === "dormant" + ? "dormant for " + age(aged("dormantForSecs", peer.dormantForSecs)) : "" }, peer.status), el("td", {}, age(aged("lastSeenSecsAgo", peer.lastSeenSecsAgo))), el("td", { class: "id via", title: via }, via), el("td", {}, rtt), diff --git a/src/api/peers.rs b/src/api/peers.rs index cd9381f..2bdde96 100644 --- a/src/api/peers.rs +++ b/src/api/peers.rs @@ -50,6 +50,9 @@ pub(crate) struct PeerStatus { /// How long its heads have differed from ours with no sync message from it in /// between; absent while they match. pub(crate) diverged_for_secs: Option, + /// Whether we refuse it as a whole, its own change having broken the swarm's rules + /// (`access::screen`): then we send it nothing. + pub(crate) refused: Option, pub(crate) has_pending_delivery: bool, /// Epoch the peer last advertised; absent until it has sent a message. pub(crate) epoch: Option, @@ -113,6 +116,16 @@ pub(crate) struct ThroughputInfo { pub(crate) secs_ago: u64, } +/// A peer refused as a whole (`PeerStatus::refused`). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RefusedPeer { + pub(crate) secs_ago: u64, + /// The change of its that broke the rules, and why. + pub(crate) change: String, + pub(crate) why: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct PeerListResponse { @@ -176,6 +189,17 @@ pub(crate) async fn peer_list(state: &AppState) -> PeerListResponse { samples: estimate.samples, secs_ago: now.saturating_duration_since(estimate.at).as_secs(), }); + let refused = { + state + .screening + .lock_or_recover() + .refusing(&peer) + .map(|refusing| RefusedPeer { + secs_ago: now.saturating_duration_since(refusing.since).as_secs(), + change: refusing.change.to_string(), + why: refusing.why.clone(), + }) + }; let ps = peer_state_mutex.lock().await; let (status, dormant_for_secs) = match ps.liveness { @@ -223,6 +247,7 @@ pub(crate) async fn peer_list(state: &AppState) -> PeerListResponse { diverged_for_secs: ps .diverged_since .map(|t| now.saturating_duration_since(t).as_secs()), + refused, has_pending_delivery: ps.sync.sending.is_some() || ps.sync.retry_at.is_some(), epoch: ps.epoch.map(EpochRecord::from), same_epoch: ps.epoch.map(|epoch| epoch == local_epoch), diff --git a/src/api/stats.rs b/src/api/stats.rs index 8eaff0c..1f0701d 100644 --- a/src/api/stats.rs +++ b/src/api/stats.rs @@ -127,6 +127,9 @@ struct SyncStats { last_from_neighbour_secs: Option, /// Peers whose heads differ from ours while sync is idle (`peers.md`, healing). diverged_peers: usize, + /// Peers refused as a whole, their own changes having broken the swarm's rules + /// (`access::screen`): sent nothing. + refused_peers: usize, /// The largest sync message received since start (its Automerge part). Larger /// transfers come as deltas (blobs). largest_received_bytes: u64, @@ -324,11 +327,15 @@ pub(crate) async fn collect(state: &AppState, list: &PeerListResponse) -> StatsR let mut incidents = Incidents::default(); let mut last_from_neighbour_secs: Option = None; let mut diverged_peers = 0; + let mut refused_peers = 0; for peer in list.peers.values() { incidents.add(&peer.incidents); if peer.diverged_for_secs.is_some() { diverged_peers += 1; } + if peer.refused.is_some() { + refused_peers += 1; + } if peer.neighbour && let Some(secs) = peer.last_sync_secs_ago { @@ -370,6 +377,7 @@ pub(crate) async fn collect(state: &AppState, list: &PeerListResponse) -> StatsR sync: SyncStats { last_from_neighbour_secs, diverged_peers, + refused_peers, largest_received_bytes: node.largest_sync_received, }, relay: RelayStats { diff --git a/src/api/stats/prometheus.rs b/src/api/stats/prometheus.rs index 038f739..41d224a 100644 --- a/src/api/stats/prometheus.rs +++ b/src/api/stats/prometheus.rs @@ -634,6 +634,12 @@ pub(super) fn render(stats: &StatsResponse, list: &PeerListResponse) -> String { "Peers whose heads differ from ours while sync is idle.", stats.sync.diverged_peers as f64, ); + out.single( + "refused_peers", + "gauge", + "Peers refused as a whole, their own changes having broken the swarm's rules.", + stats.sync.refused_peers as f64, + ); let relay = &stats.relay; out.single( "announce_rounds_total", -- cgit v1.3.1