diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index db3f62368..ad7a71e34 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -152,6 +152,7 @@ fn beacon_identity( ], discovery_addresses: vec![format!("/{family}/{ip}/udp/{discovery_port}/p2p/{peer_id}")], metadata: None, + peers: None, } } @@ -810,6 +811,7 @@ async fn run_node(options: Options) -> eyre::Result<()> { }; let rpc_identity = ethlambda_rpc::BeaconIdentity { metadata: p2p.local_metadata(), + peers: Some(p2p.peer_table()), ..beacon_identity( p2p.local_enr().unwrap_or_default(), common.discovery.advertise_ip, diff --git a/crates/net/api/src/lib.rs b/crates/net/api/src/lib.rs index a0aa347d2..317559ba2 100644 --- a/crates/net/api/src/lib.rs +++ b/crates/net/api/src/lib.rs @@ -515,6 +515,138 @@ impl MetaDataController { } } +/// Which side opened a peer's first connection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PeerDirection { + /// The remote dialed us. + Inbound, + /// We dialed the remote. + Outbound, +} + +impl PeerDirection { + /// The value the Beacon API's `Peer.direction` carries. + pub const fn as_str(self) -> &'static str { + match self { + Self::Inbound => "inbound", + Self::Outbound => "outbound", + } + } +} + +/// One connected peer, as the Beacon API's `/eth/v1/node/peers` reports it. +/// +/// Strings rather than libp2p types, so this crate stays free of libp2p. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConnectedPeer { + /// The peer's libp2p id, base58. + pub peer_id: String, + pub direction: PeerDirection, + /// The remote address of the connection that made the peer connected, ending + /// in `/p2p/`. + pub last_seen_p2p_address: String, + /// The peer's `enr:`-prefixed record, when discovery supplied one before we + /// dialed it. `None` for an inbound peer, whose record we never fetch. + pub enr: Option, +} + +/// Shared, runtime-readable table of the peers this node is connected to. +/// +/// The p2p layer is the sole writer: it inserts a peer when its first +/// connection is established and removes it when its last one closes, so only +/// connected peers are ever in the table. The Beacon API's +/// `/eth/v1/node/peers` clones the handle and reads it at request time. +#[derive(Clone, Debug, Default)] +pub struct PeerTableController { + peers: Arc>>, +} + +impl PeerTableController { + /// Record a peer as connected, replacing any earlier entry for it. + pub fn insert(&self, peer: ConnectedPeer) { + self.peers + .write() + .expect("peer table lock poisoned") + .insert(peer.peer_id.clone(), peer); + } + + /// Drop a peer that no longer holds a connection. + pub fn remove(&self, peer_id: &str) { + self.peers + .write() + .expect("peer table lock poisoned") + .remove(peer_id); + } + + /// One connected peer, if it is. + pub fn get(&self, peer_id: &str) -> Option { + self.peers + .read() + .expect("peer table lock poisoned") + .get(peer_id) + .cloned() + } + + /// Every connected peer, ordered by peer id so a response is stable + /// between polls. + pub fn snapshot(&self) -> Vec { + let mut peers: Vec<_> = self + .peers + .read() + .expect("peer table lock poisoned") + .values() + .cloned() + .collect(); + peers.sort_by(|a, b| a.peer_id.cmp(&b.peer_id)); + peers + } +} + +#[cfg(test)] +mod peer_table_tests { + use super::*; + + fn peer(id: &str, direction: PeerDirection) -> ConnectedPeer { + ConnectedPeer { + peer_id: id.to_string(), + direction, + last_seen_p2p_address: format!("/ip4/10.0.0.1/tcp/9000/p2p/{id}"), + enr: None, + } + } + + #[test] + fn a_peer_is_present_from_insert_until_remove() { + let table = PeerTableController::default(); + table.insert(peer("b", PeerDirection::Inbound)); + assert_eq!(table.get("b").unwrap().direction, PeerDirection::Inbound); + + table.remove("b"); + assert!(table.get("b").is_none()); + assert!(table.snapshot().is_empty()); + } + + #[test] + fn inserting_twice_keeps_one_entry_with_the_latest_value() { + let table = PeerTableController::default(); + table.insert(peer("a", PeerDirection::Inbound)); + table.insert(peer("a", PeerDirection::Outbound)); + let peers = table.snapshot(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].direction, PeerDirection::Outbound); + } + + #[test] + fn the_snapshot_is_ordered_by_peer_id_and_shared_between_clones() { + let table = PeerTableController::default(); + let reader = table.clone(); + table.insert(peer("c", PeerDirection::Outbound)); + table.insert(peer("a", PeerDirection::Inbound)); + let ids: Vec<_> = reader.snapshot().into_iter().map(|p| p.peer_id).collect(); + assert_eq!(ids, ["a", "c"]); + } +} + // --- Init messages --- // Used to wire actors together after spawn. diff --git a/crates/net/p2p/src/discovery/admission.rs b/crates/net/p2p/src/discovery/admission.rs index 6a1c6adf5..b9ba490a2 100644 --- a/crates/net/p2p/src/discovery/admission.rs +++ b/crates/net/p2p/src/discovery/admission.rs @@ -54,6 +54,9 @@ pub(crate) struct DiscoveredPeer { /// `P2PServer::record_peer_custody`), and this only fills the gap until /// that answer arrives. Lighthouse splits the two the same way. pub(crate) custody_group_count: Option, + /// The record as an `enr:` string, kept for the Beacon API's peer list. + /// `None` only if the record cannot be re-encoded. + pub(crate) enr: Option, } /// Why a discovered peer was turned away. @@ -235,6 +238,7 @@ fn admit( addrs: dial_addrs(ip, quic_port, tcp_port, peer_id), subnets, custody_group_count, + enr: record.enr_url().ok(), }) } @@ -396,6 +400,7 @@ mod tests { addrs: vec![Multiaddr::empty()], subnets, custody_group_count: None, + enr: None, } } } @@ -730,6 +735,7 @@ mod tests { addrs: vec![Multiaddr::empty()], subnets: vec![], custody_group_count: Some(custody_group_count), + enr: None, } } diff --git a/crates/net/p2p/src/discovery/dial.rs b/crates/net/p2p/src/discovery/dial.rs index 229573c26..bcf4cc127 100644 --- a/crates/net/p2p/src/discovery/dial.rs +++ b/crates/net/p2p/src/discovery/dial.rs @@ -36,6 +36,10 @@ pub(crate) struct DiscoveryState { candidates: VecDeque, /// Subnets advertised by peers we dialed from discovery. peer_attnets: HashMap>, + /// The ENR of each peer we dialed from discovery, until its connection + /// ends or its dial fails. Read once, when the connection is established, + /// for the Beacon API's peer list. + peer_enrs: HashMap, /// Our own peer ID, so the loop never dials itself. local_peer_id: PeerId, /// Connected-peer count above which the loop stops dialing. @@ -53,6 +57,7 @@ impl DiscoveryState { contacts: spawn_contact_poll(handle.peer_table, handle.filter.clone()), candidates: VecDeque::new(), peer_attnets: HashMap::new(), + peer_enrs: HashMap::new(), local_peer_id, target_peers: handle.target_peers, filter: handle.filter, @@ -68,6 +73,11 @@ impl DiscoveryState { pub(crate) fn target_peers(&self) -> usize { self.target_peers } + + /// The ENR discovery supplied for a peer we dialed, if any. + pub(crate) fn enr_of(&self, peer_id: &PeerId) -> Option { + self.peer_enrs.get(peer_id).cloned() + } } /// How long the poll task waits after the peer table offers nothing. @@ -93,6 +103,7 @@ const CONTACT_POLL_IDLE: Duration = Duration::from_millis(250); pub(crate) fn forget_discovered_peer(server: &mut P2PServer, peer_id: &PeerId) { if let Some(discovery) = server.discovery.as_mut() { discovery.peer_attnets.remove(peer_id); + discovery.peer_enrs.remove(peer_id); } // Custody is keyed by peer id and a peer id is a public key, so a returning // peer recomputes to the same set. Dropped anyway: the map is only ever @@ -328,6 +339,9 @@ pub(crate) async fn dial_tick(server: &mut P2PServer, target_peers: usize) -> bo discovery .peer_attnets .insert(candidate.peer_id, candidate.subnets); + if let Some(enr) = candidate.enr { + discovery.peer_enrs.insert(candidate.peer_id, enr); + } } } dialed diff --git a/crates/net/p2p/src/lib.rs b/crates/net/p2p/src/lib.rs index cc9ca2bd1..3e4d1872e 100644 --- a/crates/net/p2p/src/lib.rs +++ b/crates/net/p2p/src/lib.rs @@ -38,7 +38,8 @@ use std::{ use either::Either; use ethlambda_network_api::{ - FetchRequest, InitBlockChain, MetaDataController, P2PToBlockChainRef, + ConnectedPeer, FetchRequest, InitBlockChain, MetaDataController, P2PToBlockChainRef, + PeerDirection, PeerTableController, block_chain_to_p2p::{ CheckDataColumnSidecars, FetchBlock, PublishAggregatedAttestation, PublishAttestation, PublishBlock, @@ -1154,6 +1155,9 @@ pub struct P2P { /// The beacon `MetaData` this node answers peers with, for the same /// endpoint. `None` on a lean wire, which has no `MetaData`. local_metadata: Option, + /// The peers this node is connected to, for the Beacon API's + /// `/eth/v1/node/peers`. + peer_table: PeerTableController, } impl P2P { @@ -1212,6 +1216,7 @@ impl P2P { .beacon() .map(|beacon| beacon::scoring::PeerScoring::new(&beacon.config, beacon.fork_digest)); let local_metadata = built.wire.beacon().map(|beacon| beacon.metadata.clone()); + let peer_table = PeerTableController::default(); let server = P2PServer { swarm_handle, @@ -1219,6 +1224,7 @@ impl P2P { blockchain: None, wire: built.wire, connected_peers: HashMap::new(), + peer_table: peer_table.clone(), peer_custody: HashMap::new(), pending_root_requests: HashMap::new(), pending_envelope_requests: HashMap::new(), @@ -1303,6 +1309,7 @@ impl P2P { handle, local_enr, local_metadata, + peer_table, }) } @@ -1323,6 +1330,12 @@ impl P2P { pub fn local_metadata(&self) -> Option { self.local_metadata.clone() } + + /// A live view of the peers this node is connected to. Written only by + /// the P2P actor. + pub fn peer_table(&self) -> PeerTableController { + self.peer_table.clone() + } } /// Message wrapper for swarm events. Not part of the protocol because @@ -1351,6 +1364,9 @@ pub struct P2PServer { /// direction is the first connection's, which is the one that decides /// whether this peer was our choice or the network's. pub(crate) connected_peers: HashMap, + /// [`Self::connected_peers`] plus what the Beacon API reports about each + /// peer, shared with the RPC layer. This actor is its only writer. + pub(crate) peer_table: PeerTableController, /// The columns each peer custodies, as computed from its own node id and /// its advertised custody group count. /// @@ -1503,6 +1519,40 @@ impl P2PServer { self.refresh_custody_column_metrics(); } + /// Publish a newly connected peer to the table the Beacon API reads. + /// + /// `remote` is the address of the connection that made the peer connected; + /// `/p2p/` is appended unless the transport already carries it. + /// The ENR is whatever the dial loop kept from discovery, so it is absent + /// for an inbound peer and for a bootnode dialed from `--bootnodes`. + fn publish_connected_peer( + &self, + peer_id: PeerId, + direction: ConnectionDirection, + remote: &Multiaddr, + ) { + let mut address = remote.clone(); + if !address + .iter() + .any(|protocol| matches!(protocol, Protocol::P2p(_))) + { + address.push(Protocol::P2p(peer_id)); + } + let direction = match direction { + ConnectionDirection::Inbound => PeerDirection::Inbound, + ConnectionDirection::Outbound => PeerDirection::Outbound, + }; + self.peer_table.insert(ConnectedPeer { + peer_id: peer_id.to_string(), + direction, + last_seen_p2p_address: address.to_string(), + enr: self + .discovery + .as_ref() + .and_then(|discovery| discovery.enr_of(&peer_id)), + }); + } + /// Publish, per column this node samples, how many connected peers are /// known to custody it. /// @@ -1971,6 +2021,7 @@ async fn handle_swarm_event( metrics::inc_peer_connection_transport(direction.as_str(), transport); if num_established.get() == 1 { server.connected_peers.insert(peer_id, direction); + server.publish_connected_peer(peer_id, direction, endpoint.get_remote_address()); server.refresh_peer_metrics(); let peer_count = server.connected_peers.len(); metrics::notify_peer_connected( @@ -2071,6 +2122,7 @@ async fn handle_swarm_event( .connected_peers .remove(&peer_id) .unwrap_or(closed_direction); + server.peer_table.remove(&peer_id.to_string()); forget_discovered_peer(server, &peer_id); server.refresh_peer_metrics(); let peer_count = server.connected_peers.len(); @@ -3035,6 +3087,7 @@ pub(crate) mod test_support { blockchain: None, wire: built.wire, connected_peers: HashMap::new(), + peer_table: ethlambda_network_api::PeerTableController::default(), peer_custody: HashMap::new(), pending_root_requests: HashMap::new(), pending_envelope_requests: HashMap::new(), diff --git a/crates/net/p2p/src/req_resp/handlers.rs b/crates/net/p2p/src/req_resp/handlers.rs index 5801a84f2..cc15d8b32 100644 --- a/crates/net/p2p/src/req_resp/handlers.rs +++ b/crates/net/p2p/src/req_resp/handlers.rs @@ -2773,6 +2773,7 @@ pub(crate) mod tests { blockchain: None, wire: built.wire, connected_peers: HashMap::new(), + peer_table: ethlambda_network_api::PeerTableController::default(), peer_custody: HashMap::new(), pending_root_requests: HashMap::new(), pending_envelope_requests: HashMap::new(), diff --git a/crates/net/rpc/src/beacon/node.rs b/crates/net/rpc/src/beacon/node.rs index 0f8081dec..b56029ca0 100644 --- a/crates/net/rpc/src/beacon/node.rs +++ b/crates/net/rpc/src/beacon/node.rs @@ -1,15 +1,18 @@ -//! `/eth/v1/node/{syncing,identity,version,health}`. +//! `/eth/v1/node/{syncing,identity,version,health,peers,peers/{peer_id},peer_count}`. use axum::{ Extension, Router, - extract::State, + extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, }; use ethlambda_blockchain::{SyncStatusController, metrics::SyncStatus}; +use ethlambda_network_api::ConnectedPeer; use ethlambda_storage::Store; +use super::ApiError; + pub(crate) fn routes(version: &'static str, peer_id: String) -> Router { Router::new() .route("/eth/v1/node/syncing", get(get_syncing)) @@ -24,6 +27,9 @@ pub(crate) fn routes(version: &'static str, peer_id: String) -> Router { ) }), ) + .route("/eth/v1/node/peers", get(get_peers)) + .route("/eth/v1/node/peers/{peer_id}", get(get_peer)) + .route("/eth/v1/node/peer_count", get(get_peer_count)) } /// The wall-clock slot, from genesis and the configured slot duration. @@ -135,6 +141,127 @@ async fn get_identity(peer_id: String, identity: Option) })) } +/// The `PeerConnectionState` values the Beacon API names. A node tracks only +/// connected peers, so `connected` is the one state that can match anything. +const PEER_STATES: [&str; 4] = ["disconnected", "connecting", "connected", "disconnecting"]; +const PEER_DIRECTIONS: [&str; 2] = ["inbound", "outbound"]; + +/// The connected peers, empty without a p2p layer to ask. +fn connected_peers(identity: Option>) -> Vec { + identity + .and_then(|Extension(identity)| identity.peers) + .map(|peers| peers.snapshot()) + .unwrap_or_default() +} + +fn peer_json(peer: &ConnectedPeer) -> serde_json::Value { + serde_json::json!({ + "peer_id": peer.peer_id, + "enr": peer.enr, + "last_seen_p2p_address": peer.last_seen_p2p_address, + "state": "connected", + "direction": peer.direction.as_str(), + }) +} + +/// Every value a repeatable query parameter names, each checked against +/// `allowed`. Values may also be comma-separated, which some clients send in +/// place of repeating the parameter. +fn filter_values( + query: &[(String, String)], + name: &str, + allowed: &[&str], +) -> Result, ApiError> { + let mut values = Vec::new(); + for (key, raw) in query { + if key != name { + continue; + } + for value in raw.split(',') { + if !allowed.contains(&value) { + return Err(ApiError::BadRequestDetail(format!( + "Invalid {name}: {value}" + ))); + } + values.push(value.to_string()); + } + } + Ok(values) +} + +/// `GET /eth/v1/node/peers`: the connected peers, filtered by the optional +/// `state` and `direction` parameters (AND-combined; values within one +/// parameter are alternatives). +/// +/// Only connected peers are tracked, so every peer reported is `connected` and +/// a `state` filter naming any other state matches nothing. +async fn get_peers( + Query(query): Query>, + identity: Option>, +) -> Response { + let (states, directions) = match ( + filter_values(&query, "state", &PEER_STATES), + filter_values(&query, "direction", &PEER_DIRECTIONS), + ) { + (Ok(states), Ok(directions)) => (states, directions), + (Err(err), _) | (_, Err(err)) => return err.into_response(), + }; + let state_matches = states.is_empty() || states.iter().any(|state| state == "connected"); + let data: Vec<_> = connected_peers(identity) + .iter() + .filter(|peer| { + state_matches + && (directions.is_empty() + || directions.iter().any(|d| d == peer.direction.as_str())) + }) + .map(peer_json) + .collect(); + crate::json_response(serde_json::json!({ + "data": data, + "meta": { "count": data.len() }, + })) +} + +/// Whether `peer_id` is spelled like a libp2p peer id: non-empty base58. The +/// text is not decoded, since this crate has no libp2p; a well-formed id that +/// is not connected is a 404. +fn is_well_formed_peer_id(peer_id: &str) -> bool { + const BASE58: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + !peer_id.is_empty() && peer_id.chars().all(|c| BASE58.contains(c)) +} + +/// `GET /eth/v1/node/peers/{peer_id}`: one connected peer. `400` for a +/// malformed id, `404` for one that is not connected. +async fn get_peer( + Path(peer_id): Path, + identity: Option>, +) -> Response { + if !is_well_formed_peer_id(&peer_id) { + return ApiError::BadRequestDetail(format!("Invalid peer id: {peer_id}")).into_response(); + } + match connected_peers(identity) + .iter() + .find(|peer| peer.peer_id == peer_id) + { + Some(peer) => crate::json_response(serde_json::json!({ "data": peer_json(peer) })), + None => ApiError::NotFound("peer not found").into_response(), + } +} + +/// `GET /eth/v1/node/peer_count`: peers per state, as quoted integers. Only +/// `connected` is ever non-zero. +async fn get_peer_count(identity: Option>) -> Response { + let connected = connected_peers(identity).len(); + crate::json_response(serde_json::json!({ + "data": { + "disconnected": "0", + "connecting": "0", + "connected": connected.to_string(), + "disconnecting": "0", + } + })) +} + #[cfg(test)] mod tests { use super::*; @@ -258,6 +385,7 @@ mod tests { p2p_addresses: vec!["/ip4/10.0.0.1/tcp/9001/p2p/test-peer".to_string()], discovery_addresses: vec!["/ip4/10.0.0.1/udp/9000/p2p/test-peer".to_string()], metadata: None, + peers: None, }; let app = routes("ethlambda/test", "test-peer".into()) .with_state(fixture.store) @@ -307,4 +435,152 @@ mod tests { assert_eq!(json["data"]["metadata"]["attnets"], "0x0010000000000001"); assert_eq!(json["data"]["metadata"]["syncnets"], "0x04"); } + + fn peer( + id: &str, + direction: ethlambda_network_api::PeerDirection, + enr: Option<&str>, + ) -> ConnectedPeer { + ConnectedPeer { + peer_id: id.to_string(), + direction, + last_seen_p2p_address: format!("/ip4/10.0.0.1/tcp/9000/p2p/{id}"), + enr: enr.map(str::to_string), + } + } + + /// Two inbound peers and one outbound, the outbound one with an ENR. + async fn get_from_table(uri: &str) -> axum::response::Response { + use ethlambda_network_api::PeerDirection::{Inbound, Outbound}; + let table = ethlambda_network_api::PeerTableController::default(); + table.insert(peer("16Uiua", Inbound, None)); + table.insert(peer("16Uiub", Outbound, Some("enr:-abc"))); + table.insert(peer("16Uiuc", Inbound, None)); + let identity = crate::BeaconIdentity { + peers: Some(table), + ..Default::default() + }; + let app = routes("ethlambda/test", "test-peer".into()) + .with_state(beacon_fixture(ANCHOR_SLOT).store) + .layer(Extension(identity)); + app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap() + } + + fn ids(json: &serde_json::Value) -> Vec<&str> { + json["data"] + .as_array() + .unwrap() + .iter() + .map(|peer| peer["peer_id"].as_str().unwrap()) + .collect() + } + + #[tokio::test] + async fn peers_lists_everyone_with_a_count_and_a_null_enr_when_unknown() { + let json = body_json(get_from_table("/eth/v1/node/peers").await).await; + assert_eq!(ids(&json), ["16Uiua", "16Uiub", "16Uiuc"]); + assert_eq!(json["meta"]["count"], 3); + assert!(json["data"][0]["enr"].is_null()); + assert_eq!(json["data"][1]["enr"], "enr:-abc"); + assert_eq!(json["data"][0]["state"], "connected"); + assert_eq!(json["data"][0]["direction"], "inbound"); + assert_eq!( + json["data"][0]["last_seen_p2p_address"], + "/ip4/10.0.0.1/tcp/9000/p2p/16Uiua" + ); + } + + #[tokio::test] + async fn peers_filter_by_direction_and_state_together() { + let json = body_json(get_from_table("/eth/v1/node/peers?direction=outbound").await).await; + assert_eq!(ids(&json), ["16Uiub"]); + assert_eq!(json["meta"]["count"], 1); + + let json = + body_json(get_from_table("/eth/v1/node/peers?state=connected&direction=inbound").await) + .await; + assert_eq!(ids(&json), ["16Uiua", "16Uiuc"]); + + // Repeated and comma-separated values are alternatives. + let json = body_json( + get_from_table("/eth/v1/node/peers?direction=inbound&direction=outbound").await, + ) + .await; + assert_eq!(json["meta"]["count"], 3); + let json = + body_json(get_from_table("/eth/v1/node/peers?direction=inbound,outbound").await).await; + assert_eq!(json["meta"]["count"], 3); + } + + #[tokio::test] + async fn a_state_other_than_connected_matches_nothing() { + let json = body_json(get_from_table("/eth/v1/node/peers?state=disconnected").await).await; + assert_eq!(json["data"], serde_json::json!([])); + assert_eq!(json["meta"]["count"], 0); + + // `connected` among alternatives still matches. + let json = + body_json(get_from_table("/eth/v1/node/peers?state=connecting&state=connected").await) + .await; + assert_eq!(json["meta"]["count"], 3); + } + + #[tokio::test] + async fn an_unknown_filter_value_is_a_400() { + for uri in [ + "/eth/v1/node/peers?state=sleeping", + "/eth/v1/node/peers?direction=sideways", + "/eth/v1/node/peers?state=connected&direction=sideways", + ] { + let response = get_from_table(uri).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}"); + let json = body_json(response).await; + assert_eq!(json["code"], 400); + assert!(json["message"].as_str().unwrap().starts_with("Invalid ")); + } + } + + #[tokio::test] + async fn peers_without_a_peer_table_is_empty() { + let json = body_json(get("/eth/v1/node/peers").await).await; + assert_eq!(json["data"], serde_json::json!([])); + assert_eq!(json["meta"]["count"], 0); + let json = body_json(get("/eth/v1/node/peer_count").await).await; + assert_eq!(json["data"]["connected"], "0"); + } + + #[tokio::test] + async fn one_peer_is_found_missed_or_rejected_as_malformed() { + let response = get_from_table("/eth/v1/node/peers/16Uiub").await; + assert_eq!(response.status(), StatusCode::OK); + let json = body_json(response).await; + assert_eq!(json["data"]["peer_id"], "16Uiub"); + assert_eq!(json["data"]["direction"], "outbound"); + assert_eq!(json["data"]["enr"], "enr:-abc"); + + let response = get_from_table("/eth/v1/node/peers/16Uiuz").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // `-` and `0` are not in the base58 alphabet. + let response = get_from_table("/eth/v1/node/peers/not-a-peer").await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = get_from_table("/eth/v1/node/peers/16Uiu0").await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn peer_count_quotes_every_state_and_counts_only_connected() { + let json = body_json(get_from_table("/eth/v1/node/peer_count").await).await; + assert_eq!( + json["data"], + serde_json::json!({ + "disconnected": "0", + "connecting": "0", + "connected": "3", + "disconnecting": "0", + }) + ); + } } diff --git a/crates/net/rpc/src/lib.rs b/crates/net/rpc/src/lib.rs index 4870c2781..08592992f 100644 --- a/crates/net/rpc/src/lib.rs +++ b/crates/net/rpc/src/lib.rs @@ -206,6 +206,10 @@ pub struct BeaconIdentity { /// since its sequence number and `syncnets` change at runtime. `None` /// without a beacon p2p layer, which leaves `metadata` all-zero. pub metadata: Option, + /// The peers the p2p layer is connected to, read per request by + /// `/eth/v1/node/peers`. `None` without a p2p layer, which leaves the list + /// empty. + pub peers: Option, } /// What the Beacon API's endpoints reach beyond the store. diff --git a/docs/rpc.md b/docs/rpc.md index bdcc260ec..e7e7115c8 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -241,6 +241,9 @@ surface rather than sitting beside it; a `/lean/v0` path on a beacon node is a | `GET` | `/eth/v1/node/health` | *(status only)* | `200` caught up, `206` syncing or on an optimistic head | | `GET` | `/eth/v1/node/version` | JSON | Client version string | | `GET` | `/eth/v1/node/identity` | JSON | Peer ID, ENR and listen multiaddrs (see below) | +| `GET` | `/eth/v1/node/peers` | JSON | Connected peers, filterable by `state` and `direction` (see below) | +| `GET` | `/eth/v1/node/peers/{peer_id}` | JSON | One connected peer; `400` for a malformed id, `404` if it is not connected | +| `GET` | `/eth/v1/node/peer_count` | JSON | Peers per connection state, as quoted integers | | `GET`, `POST` | `/eth/v1/beacon/states/{state_id}/validators` | JSON | Registry entries by index or pubkey, with status | | `GET` | `/eth/v1/beacon/states/{state_id}/validators/{validator_id}` | JSON | One registry entry, by index or pubkey; `404` if there is none | | `GET` | `/eth/v1/validator/duties/proposer/{epoch}` | JSON | Proposers for any epoch from the head's up to one past the wall clock's | @@ -848,6 +851,25 @@ read at request time, so `syncnets` and `seq_number` follow the sync committee subnets a validator client asks for. That `seq_number` versions `MetaData` alone: the p2p specification counts it separately from the ENR's `seq`. +`/eth/v1/node/peers`, `/eth/v1/node/peers/{peer_id}` and +`/eth/v1/node/peer_count` read the p2p layer's table of connected peers at +request time. Only **connected** peers are tracked: a peer enters the table when +its first connection is established and leaves when its last one closes, so +`state` is always `connected`. A `state` filter naming `disconnected`, +`connecting` or `disconnecting` is valid and returns an empty list, and +`peer_count` reports `"0"` for those states. A `state` or `direction` value +outside the specification's enum is a `400`; the parameters may be repeated or +comma-separated, values of one parameter are alternatives, and the two +parameters are AND-combined. `meta.count` is the length of the filtered list, +and peers are ordered by peer id. + +`direction` is that of the peer's first connection, and `last_seen_p2p_address` +is that connection's remote address ending in `/p2p/`. `enr` is the +record discovery supplied before this node dialed the peer, and `null` when +there is none: inbound peers, and bootnodes dialed from `--bootnodes`. No +request is made to obtain it. A `peer_id` that is not base58 is a `400`; a +well-formed one that is not connected is a `404`. + ### `GET /eth/v1/events` The Beacon API eventstream: the same Server-Sent Events stream as