Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ flate2 = "1.1"
wiremock = "0.6"
tower = "0.5"
sysinfo = "0.33"
quick-xml = { version = "0.39", features = ["serialize"] }
quick-xml = { version = "0.41", features = ["serialize"] }

# Crates in the workspace
pluto-app = { path = "crates/app" }
Expand Down
38 changes: 36 additions & 2 deletions crates/app/src/monitoringapi/checker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,43 @@ async fn set_beacon_node_version(beacon_node: &EthBeaconNodeApiClient) {
}
};

// The BN version string is upstream-controlled and unbounded in length;
// truncate it for the metric label (a hostile/buggy BN could return a
// multi-MB string). The reset loop compares against the same truncated form
// so the stale series is cleared correctly.
let label = truncate_label(&version);

// Emulate Charon's `beaconNodeVersionGauge.Reset`: vise's `Family` cannot
// delete series, so clear any previously-reported version before setting the
// current one.
for (previous, gauge) in MONITORING_METRICS.beacon_node_version.to_entries() {
if previous != version {
if previous != label {
gauge.set(0);
}
}
MONITORING_METRICS.beacon_node_version[&version].set(1);
MONITORING_METRICS.beacon_node_version[&label].set(1);

// The semantic compatibility check uses the FULL (untruncated) version.
check_beacon_node_version(&version);
}

/// Maximum length (in bytes) for the upstream-supplied beacon-node version
/// metric label. A real BN version (e.g. "Lighthouse/v5.1.3-...") is short.
const MAX_METRIC_LABEL_LEN: usize = 64;

/// Truncates a label value to [`MAX_METRIC_LABEL_LEN`] on a UTF-8 char
/// boundary.
fn truncate_label(s: &str) -> String {
if s.len() <= MAX_METRIC_LABEL_LEN {
return s.to_string();
}
let mut end = MAX_METRIC_LABEL_LEN;
while end > 0 && !s.is_char_boundary(end) {
end = end.saturating_sub(1);
}
s[..end].to_string()
}

async fn fetch_node_version(
beacon_node: &EthBeaconNodeApiClient,
) -> Result<String, ReadyCheckerError> {
Expand Down Expand Up @@ -449,6 +473,16 @@ mod tests {
Keypair::generate_secp256k1().public().to_peer_id()
}

#[test]
fn truncate_label_bounds_length() {
assert_eq!(truncate_label("Lighthouse/v5.1.3"), "Lighthouse/v5.1.3");
let long = "x".repeat(MAX_METRIC_LABEL_LEN + 500);
assert_eq!(truncate_label(&long).len(), MAX_METRIC_LABEL_LEN);
// Multibyte input truncates on a char boundary without panicking.
let mb = "é".repeat(MAX_METRIC_LABEL_LEN);
assert!(truncate_label(&mb).len() <= MAX_METRIC_LABEL_LEN);
}

fn pubkey(byte: u8) -> PubKey {
PubKey::from([byte; 48])
}
Expand Down
58 changes: 54 additions & 4 deletions crates/app/src/obolapi/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use std::time::Duration;

use bon::Builder;
use futures::StreamExt;
use pluto_cluster::lock::Lock;
use reqwest::{Method, StatusCode};
use url::Url;
Expand All @@ -15,6 +16,56 @@ use crate::obolapi::error::{Error, Result};
/// Default HTTP request timeout if not specified.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);

/// Maximum body size read from a successful Obol API response (16 MB). A
/// full-exit response holds up to `share_count` hex BLS signatures; 16 MB is
/// orders of magnitude above any real cluster, so this never rejects a
/// legitimate response while bounding memory against a hostile upstream.
const OBOLAPI_MAX_BODY: usize = 16 * 1024 * 1024;

/// Maximum body size read from an HTTP error response (diagnostics only).
const OBOLAPI_MAX_ERROR_BODY: usize = 64 * 1024;

/// Reads a response body, failing with [`Error::BodyTooLarge`] if it would
/// exceed `max` bytes. Streams so the cap is enforced before full buffering.
async fn read_body_capped(response: reqwest::Response, max: usize) -> Result<Vec<u8>> {
// Fast-path reject if the server advertised an oversized length.
if let Some(len) = response.content_length()
&& len > max as u64
{
return Err(Error::BodyTooLarge { limit: max });
}

let mut buf = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if buf.len().saturating_add(chunk.len()) > max {
return Err(Error::BodyTooLarge { limit: max });
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}

/// Reads an HTTP error body for diagnostics, truncating at `max` bytes so a
/// hostile error response cannot exhaust memory. Never fails (best-effort).
async fn read_error_body(response: reqwest::Response, max: usize) -> String {
let mut buf = Vec::new();
let mut stream = response.bytes_stream();
while let Some(Ok(chunk)) = stream.next().await {
let remaining = max.saturating_sub(buf.len());
if remaining == 0 {
break;
}
let take = remaining.min(chunk.len());
buf.extend_from_slice(&chunk[..take]);
if buf.len() >= max {
break;
}
}
String::from_utf8_lossy(&buf).into_owned()
}

/// REST client for Obol API requests.
#[derive(Debug, Clone)]
pub struct Client {
Expand Down Expand Up @@ -98,7 +149,7 @@ impl Client {

let status = response.status();
if !status.is_success() {
let body_text = response.text().await.unwrap_or_default();
let body_text = read_error_body(response, OBOLAPI_MAX_ERROR_BODY).await;

return Err(Error::HttpError {
method: Method::POST,
Expand Down Expand Up @@ -133,7 +184,7 @@ impl Client {
return Err(Error::NoExit);
}

let body_text = response.text().await.unwrap_or_default();
let body_text = read_error_body(response, OBOLAPI_MAX_ERROR_BODY).await;

return Err(Error::HttpError {
method: Method::GET,
Expand All @@ -142,8 +193,7 @@ impl Client {
});
}

let body_bytes = response.bytes().await?.to_vec();
Ok(body_bytes)
read_body_capped(response, OBOLAPI_MAX_BODY).await
}

/// Makes an HTTP DELETE request.
Expand Down
7 changes: 7 additions & 0 deletions crates/app/src/obolapi/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ pub enum Error {
#[error("HTTP client error: {0}")]
Reqwest(#[from] reqwest::Error),

/// Response body exceeded the allowed size.
#[error("response body exceeds {limit} bytes")]
BodyTooLarge {
/// The enforced maximum body size in bytes.
limit: usize,
},

/// JSON serialization/deserialization error.
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
Expand Down
20 changes: 16 additions & 4 deletions crates/cli/src/commands/test/beacon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ use tokio::{
};
use tokio_util::sync::CancellationToken;

/// Per-request timeout for beacon-node diagnostic HTTP calls, so a hostile or
/// slow endpoint cannot stall a diagnostic indefinitely.
const BEACON_HTTP_TIMEOUT: StdDuration = StdDuration::from_secs(10);

/// Builds a diagnostic HTTP client with a request timeout.
fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(BEACON_HTTP_TIMEOUT)
.build()
.unwrap_or_default()
}

const THRESHOLD_BEACON_MEASURE_AVG: StdDuration = StdDuration::from_millis(40);
const THRESHOLD_BEACON_MEASURE_POOR: StdDuration = StdDuration::from_millis(100);
const THRESHOLD_BEACON_LOAD_AVG: StdDuration = StdDuration::from_millis(40);
Expand Down Expand Up @@ -448,7 +460,7 @@ async fn beacon_version_test(
let mut res = TestResult::new("Version");
let url = format!("{target}/eth/v1/node/version");

let client = reqwest::Client::new();
let client = http_client();
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => return res.fail(e),
Expand Down Expand Up @@ -498,7 +510,7 @@ async fn beacon_is_synced_test(
let mut res = TestResult::new("Synced");
let url = format!("{target}/eth/v1/node/syncing");

let client = reqwest::Client::new();
let client = http_client();
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => return res.fail(e),
Expand Down Expand Up @@ -543,7 +555,7 @@ async fn beacon_peer_count_test(
let mut res = TestResult::new("PeerCount");
let url = format!("{target}/eth/v1/node/peers?state=connected");

let client = reqwest::Client::new();
let client = http_client();
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => return res.fail(e),
Expand Down Expand Up @@ -1551,7 +1563,7 @@ async fn sync_committee_message_duty(

async fn get_current_slot(target: &str) -> CliResult<u64> {
let url = format!("{target}/eth/v1/node/syncing");
let client = reqwest::Client::new();
let client = http_client();
let resp = client.get(&url).send().await?;

// More strict than the Charon check, which requires the status code to be >
Expand Down
40 changes: 33 additions & 7 deletions crates/cli/src/commands/test/mev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,32 @@ use crate::{
};
use clap::Args;

/// Per-request timeout for MEV/beacon diagnostic HTTP calls.
const MEV_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
/// Maximum diagnostic response body read from a beacon/relay endpoint (16 MB).
const BN_MAX_BODY: usize = 16 * 1024 * 1024;

/// Builds a diagnostic HTTP client with a request timeout so a hostile/slow
/// endpoint cannot stall a diagnostic indefinitely.
fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(MEV_HTTP_TIMEOUT)
.build()
.unwrap_or_default()
}

/// Reads a response body, rejecting bodies that exceed [`BN_MAX_BODY`]. Uses
/// the advertised `Content-Length` for the fast-path reject; the client timeout
/// bounds a slow/absent-length body.
async fn read_body_capped(resp: reqwest::Response) -> Result<Vec<u8>> {
if let Some(len) = resp.content_length()
&& len > BN_MAX_BODY as u64
{
return Err(MevTestError::BodyTooLarge(BN_MAX_BODY).into());
}
Ok(resp.bytes().await?.to_vec())
}

/// Thresholds for MEV ping measure test.
const THRESHOLD_MEV_MEASURE_AVG: Duration = Duration::from_millis(40);
/// Threshold for poor MEV ping measure.
Expand Down Expand Up @@ -233,7 +259,7 @@ async fn test_single_mev(
async fn mev_ping_test(target: &str, _conf: &TestMevArgs) -> TestResult {
let test_res = TestResult::new("Ping");
let url = format!("{target}/eth/v1/builder/status");
let client = reqwest::Client::new();
let client = http_client();

let resp = match client.get(&url).send().await {
Ok(r) => r,
Expand Down Expand Up @@ -459,8 +485,8 @@ struct BuilderBidResponse {

async fn latest_beacon_block(endpoint: &str) -> Result<BeaconBlockMessage> {
let url = format!("{endpoint}/eth/v2/beacon/blocks/head");
let resp = reqwest::Client::new().get(&url).send().await?;
let body = resp.bytes().await?;
let resp = http_client().get(&url).send().await?;
let body = read_body_capped(resp).await?;
let block: BeaconBlock = serde_json::from_slice(&body)?;

Ok(block.data.message)
Expand All @@ -471,8 +497,8 @@ async fn fetch_proposers_for_epoch(
epoch: i64,
) -> Result<Vec<ProposerDutiesData>> {
let url = format!("{beacon_endpoint}/eth/v1/validator/duties/proposer/{epoch}");
let resp = reqwest::Client::new().get(&url).send().await?;
let body = resp.bytes().await?;
let resp = http_client().get(&url).send().await?;
let body = read_body_capped(resp).await?;
let duties: ProposerDuties = serde_json::from_slice(&body)?;

Ok(duties.data)
Expand All @@ -497,15 +523,15 @@ async fn get_block_header(

let start = Instant::now();

let resp = reqwest::Client::new().get(&url).send().await?;
let resp = http_client().get(&url).send().await?;

let rtt = start.elapsed();

if resp.status() != StatusCode::OK {
return Err(MevTestError::StatusCodeNot200.into());
}

let body = resp.bytes().await?;
let body = read_body_capped(resp).await?;

let bid: BuilderBidResponse = serde_json::from_slice(&body)?;

Expand Down
4 changes: 4 additions & 0 deletions crates/cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ pub enum MevTestError {
#[error("status code {0}")]
HttpStatus(u16),

/// Response body exceeded the allowed size.
#[error("response body exceeds {0} bytes")]
BodyTooLarge(usize),

/// Beacon node endpoint required but not provided.
#[error("beacon-node-endpoint required when load-test enabled")]
BeaconNodeEndpointRequired,
Expand Down
3 changes: 2 additions & 1 deletion crates/cluster/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ pluto-k1util.workspace = true
pluto-ssz.workspace = true
k256.workspace = true
tokio.workspace = true
reqwest = { workspace = true, features = ["json"] }
futures.workspace = true
reqwest = { workspace = true, features = ["json", "stream"] }
# Workaround to use test code from different crate.
# See: https://github.com/NethermindEth/pluto/pull/285
pluto-testutil = { workspace = true, optional = true }
Expand Down
Loading
Loading