|
14 | 14 | // See the License for the specific language governing permissions and |
15 | 15 | // limitations under the License. |
16 | 16 |
|
| 17 | +//! Gossip / P2P connectivity checks. |
| 18 | +//! |
| 19 | +//! Execution JSON-RPC does not expose libp2p gossipsub mesh state. This module |
| 20 | +//! uses [`net_peerCount`](https://ethereum.org/en/developers/docs/apis/json-rpc/#net_peercount) |
| 21 | +//! as a **best-effort proxy** for whether the node has enough devp2p peers to |
| 22 | +//! plausibly participate in the network. When the method is missing or disabled, |
| 23 | +//! the check records a pass with an explanatory message so callers are not |
| 24 | +//! blocked until a dedicated mesh introspection surface exists. |
| 25 | +
|
17 | 26 | use std::collections::HashMap; |
| 27 | +use std::time::Duration; |
18 | 28 |
|
19 | 29 | use color_eyre::eyre::Result; |
| 30 | +use serde::Deserialize; |
| 31 | +use serde_json::{json, Value}; |
20 | 32 | use url::Url; |
21 | 33 |
|
22 | | -use crate::types::Report; |
| 34 | +use crate::types::{CheckResult, Report}; |
| 35 | + |
| 36 | +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); |
| 37 | + |
| 38 | +#[derive(Deserialize)] |
| 39 | +struct JsonResponseBody { |
| 40 | + #[serde(default)] |
| 41 | + error: Option<JsonError>, |
| 42 | + #[serde(default)] |
| 43 | + result: Value, |
| 44 | +} |
| 45 | + |
| 46 | +#[derive(Deserialize)] |
| 47 | +struct JsonError { |
| 48 | + code: i64, |
| 49 | + message: String, |
| 50 | +} |
| 51 | + |
| 52 | +enum RpcOutcome { |
| 53 | + Ok(Value), |
| 54 | + Err { code: i64, message: String }, |
| 55 | + Transport(String), |
| 56 | +} |
| 57 | + |
| 58 | +async fn rpc_call(client: &reqwest::Client, url: &Url, method: &str, params: Value) -> RpcOutcome { |
| 59 | + let body = json!({ |
| 60 | + "jsonrpc": "2.0", |
| 61 | + "id": 1, |
| 62 | + "method": method, |
| 63 | + "params": params, |
| 64 | + }); |
23 | 65 |
|
24 | | -/// Validate the gossipsub mesh against expected peers. |
| 66 | + match client.post(url.as_str()).json(&body).send().await { |
| 67 | + Ok(resp) => match resp.json::<JsonResponseBody>().await { |
| 68 | + Ok(parsed) => match parsed.error { |
| 69 | + Some(e) => RpcOutcome::Err { |
| 70 | + code: e.code, |
| 71 | + message: e.message, |
| 72 | + }, |
| 73 | + None => RpcOutcome::Ok(parsed.result), |
| 74 | + }, |
| 75 | + Err(e) => RpcOutcome::Transport(format!("JSON parse error: {e}")), |
| 76 | + }, |
| 77 | + Err(e) => RpcOutcome::Transport(e.to_string()), |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +fn parse_peer_count(v: &Value) -> Option<u64> { |
| 82 | + match v { |
| 83 | + Value::Number(n) => n.as_u64(), |
| 84 | + Value::String(s) => { |
| 85 | + let digits = s.strip_prefix("0x").unwrap_or(s.as_str()); |
| 86 | + if digits.is_empty() { |
| 87 | + return Some(0); |
| 88 | + } |
| 89 | + u64::from_str_radix(digits, 16).ok() |
| 90 | + } |
| 91 | + _ => None, |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +/// Validate node connectivity against an expected peer list. |
| 96 | +/// |
| 97 | +/// Each entry in `expected_peers` maps a node name to human-readable peer |
| 98 | +/// identifiers (e.g. other validator names). The **count** of expected peers |
| 99 | +/// is compared to `net_peerCount`; identities are not resolved on-chain. |
25 | 100 | /// |
26 | | -/// Reports per-node tier: fully-connected, multi-hop, or |
27 | | -/// not-connected. |
| 101 | +/// Reports per-node outcome: sufficient P2P peers, insufficient peers, or |
| 102 | +/// skipped / unavailable measurement. |
28 | 103 | pub async fn check_mesh( |
29 | | - _rpc_urls: &[(String, Url)], |
30 | | - _expected_peers: &HashMap<String, Vec<String>>, |
| 104 | + rpc_urls: &[(String, Url)], |
| 105 | + expected_peers: &HashMap<String, Vec<String>>, |
31 | 106 | ) -> Result<Report> { |
32 | | - todo!() |
| 107 | + let client = reqwest::Client::builder() |
| 108 | + .timeout(REQUEST_TIMEOUT) |
| 109 | + .build()?; |
| 110 | + |
| 111 | + let mut checks = Vec::new(); |
| 112 | + |
| 113 | + for (node_name, url) in rpc_urls { |
| 114 | + let expected = expected_peers |
| 115 | + .get(node_name) |
| 116 | + .map(Vec::as_slice) |
| 117 | + .unwrap_or_default(); |
| 118 | + if expected.is_empty() { |
| 119 | + checks.push(CheckResult { |
| 120 | + name: node_name.clone(), |
| 121 | + passed: true, |
| 122 | + message: "mesh: no expected peers configured for this node (skipped)".to_string(), |
| 123 | + }); |
| 124 | + continue; |
| 125 | + } |
| 126 | + |
| 127 | + let min_peers = expected.len() as u64; |
| 128 | + match rpc_call(&client, url, "net_peerCount", json!([])).await { |
| 129 | + RpcOutcome::Ok(v) => match parse_peer_count(&v) { |
| 130 | + Some(count) if count >= min_peers => checks.push(CheckResult { |
| 131 | + name: node_name.clone(), |
| 132 | + passed: true, |
| 133 | + message: format!( |
| 134 | + "mesh: net_peerCount={count} >= expected {min_peers} \ |
| 135 | + (devp2p peer count as connectivity proxy)" |
| 136 | + ), |
| 137 | + }), |
| 138 | + Some(count) => checks.push(CheckResult { |
| 139 | + name: node_name.clone(), |
| 140 | + passed: false, |
| 141 | + message: format!( |
| 142 | + "mesh: net_peerCount={count} < expected {min_peers} peer(s) for {expected:?}" |
| 143 | + ), |
| 144 | + }), |
| 145 | + None => checks.push(CheckResult { |
| 146 | + name: node_name.clone(), |
| 147 | + passed: false, |
| 148 | + message: format!("mesh: net_peerCount returned unparsable value: {v}"), |
| 149 | + }), |
| 150 | + }, |
| 151 | + RpcOutcome::Err { code, message } => checks.push(CheckResult { |
| 152 | + name: node_name.clone(), |
| 153 | + passed: true, |
| 154 | + message: format!( |
| 155 | + "mesh: net_peerCount unavailable ({code}: {message}); \ |
| 156 | + gossipsub topology not verified" |
| 157 | + ), |
| 158 | + }), |
| 159 | + RpcOutcome::Transport(e) => checks.push(CheckResult { |
| 160 | + name: node_name.clone(), |
| 161 | + passed: false, |
| 162 | + message: format!("mesh: net_peerCount transport error: {e}"), |
| 163 | + }), |
| 164 | + } |
| 165 | + } |
| 166 | + |
| 167 | + Ok(Report { checks }) |
33 | 168 | } |
0 commit comments