diff --git a/Cargo.lock b/Cargo.lock index 12e685a23e0..8c77fb005ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6235,6 +6235,7 @@ dependencies = [ "hex", "once_cell", "prometheus", + "prost-types 0.14.4", "quick_cache", "reqwest 0.12.28", "reqwest-middleware", diff --git a/packages/dapi-grpc/build.rs b/packages/dapi-grpc/build.rs index d97e8b2032c..50e0d57b6d7 100644 --- a/packages/dapi-grpc/build.rs +++ b/packages/dapi-grpc/build.rs @@ -466,6 +466,10 @@ impl MappingConfig { let builder = typ .configure(tonic_prost_build::configure()) .out_dir(out_dir.clone()) + // Emit the FileDescriptorSet alongside the generated code so + // consumers can enumerate the served rpcs at test time (e.g. + // rs-dapi asserts its metrics allowlist covers every method). + .file_descriptor_set_path(out_dir.join("descriptor.bin")) .protoc_arg("--experimental_allow_proto3_optional"); Self { diff --git a/packages/dapi-grpc/src/lib.rs b/packages/dapi-grpc/src/lib.rs index 7beaffc863d..59967bc5e24 100644 --- a/packages/dapi-grpc/src/lib.rs +++ b/packages/dapi-grpc/src/lib.rs @@ -26,6 +26,26 @@ pub mod core { env!("DAPI_GRPC_OUT_DIR"), "/core/wasm/org.dash.platform.dapi.v0.rs" )); + + /// Serialized `FileDescriptorSet` for the Core proto — lets consumers + /// enumerate the served rpcs (e.g. to assert metrics allowlists). + #[cfg(all(feature = "server", not(target_arch = "wasm32")))] + pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!( + env!("DAPI_GRPC_OUT_DIR"), + "/core/server/descriptor.bin" + )); + + /// Serialized `FileDescriptorSet` for the Core proto — lets consumers + /// enumerate the served rpcs (e.g. to assert metrics allowlists). + #[cfg(all( + feature = "client", + not(feature = "server"), + not(target_arch = "wasm32") + ))] + pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!( + env!("DAPI_GRPC_OUT_DIR"), + "/core/client/descriptor.bin" + )); } } @@ -53,6 +73,28 @@ pub mod platform { env!("DAPI_GRPC_OUT_DIR"), "/platform/wasm/org.dash.platform.dapi.v0.rs" )); + + /// Serialized `FileDescriptorSet` for the Platform proto — lets + /// consumers enumerate the served rpcs (e.g. to assert metrics + /// allowlists). + #[cfg(all(feature = "server", not(target_arch = "wasm32")))] + pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!( + env!("DAPI_GRPC_OUT_DIR"), + "/platform/server/descriptor.bin" + )); + + /// Serialized `FileDescriptorSet` for the Platform proto — lets + /// consumers enumerate the served rpcs (e.g. to assert metrics + /// allowlists). + #[cfg(all( + feature = "client", + not(feature = "server"), + not(target_arch = "wasm32") + ))] + pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!( + env!("DAPI_GRPC_OUT_DIR"), + "/platform/client/descriptor.bin" + )); } #[cfg(feature = "tenderdash-proto")] diff --git a/packages/rs-dapi/Cargo.toml b/packages/rs-dapi/Cargo.toml index 118ab3a00fd..fad8d4c67a9 100644 --- a/packages/rs-dapi/Cargo.toml +++ b/packages/rs-dapi/Cargo.toml @@ -99,6 +99,8 @@ zeroize = "1.8" tempfile = "3.13.0" serial_test = "3.1.1" test-case = "3.3.1" +# Decodes dapi-grpc's FILE_DESCRIPTOR_SET in the metrics allowlist test. +prost-types = "0.14" [package.metadata.cargo-machete] ignored = ["rs-dash-event-bus"] diff --git a/packages/rs-dapi/src/metrics.rs b/packages/rs-dapi/src/metrics.rs index 453483877c0..de86b7abcb5 100644 --- a/packages/rs-dapi/src/metrics.rs +++ b/packages/rs-dapi/src/metrics.rs @@ -6,7 +6,6 @@ use prometheus::{ register_int_gauge_vec, }; use std::any::type_name_of_val; -use std::borrow::Cow; use std::fmt; use std::future::Future; use std::pin::Pin; @@ -15,23 +14,19 @@ use std::time::Instant; use tower::{Layer, Service}; use crate::logging::middleware::{ - detect_protocol_type, extract_grpc_status, http_status_to_grpc_status, parse_grpc_path, + detect_protocol_type, extract_grpc_status, http_status_to_grpc_status, }; #[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub struct MethodLabel(Cow<'static, str>); +pub struct MethodLabel(&'static str); impl MethodLabel { pub fn from_type_name(name: &'static str) -> Self { - Self(Cow::Borrowed(name)) - } - - pub fn from_owned(name: String) -> Self { - Self(Cow::Owned(name)) + Self(name) } pub fn as_str(&self) -> &str { - &self.0 + self.0 } } @@ -500,25 +495,120 @@ where #[inline] fn endpoint_label(protocol: &str, path: &str, method_hint: Option<&MethodLabel>) -> String { - if protocol == "gRPC" { - if let Some(method) = method_hint { - return method.as_str().to_string(); - } - let (service, method) = parse_grpc_path(path); - if service == "unknown" && method == "unknown" { - path.to_string() - } else { - format!("{}/{}", service, method) + match protocol { + "gRPC" => known_grpc_endpoint(path).to_string(), + "JSON-RPC" => method_hint + .map(MethodLabel::as_str) + .unwrap_or("jsonrpc_unknown") + .to_string(), + _ => "http_unknown".to_string(), + } +} + +macro_rules! match_grpc_methods { + ($path:expr, $service:literal, [$($method:literal),+ $(,)?]) => {{ + match $path { + $(concat!("/", $service, "/", $method) => { + Some(concat!($service, "/", $method)) + },)+ + _ => None, } - } else if protocol == "JSON-RPC" { - if let Some(method) = method_hint { - method.as_str().to_string() - } else { - path.to_string() - } - } else { - path.to_string() - } + }}; +} + +/// Return a label only for methods compiled into the two public Tonic services. +/// Every other syntactically valid or malformed path shares one finite bucket. +// KEEP IN SYNC with packages/dapi-grpc/protos/{core,platform}/v0/*.proto — +// an rpc missing here still serves fine but its metrics degrade to the +// `grpc_unknown` bucket. Enforced by +// `known_grpc_endpoint_covers_every_served_rpc`, which walks dapi-grpc's +// FILE_DESCRIPTOR_SET, so forgetting to extend this list fails CI. +fn known_grpc_endpoint(path: &str) -> &'static str { + match_grpc_methods!( + path, + "org.dash.platform.dapi.v0.Core", + [ + "getBlockchainStatus", + "getMasternodeStatus", + "getBlock", + "getBestBlockHeight", + "getEstimatedTransactionFee", + "broadcastTransaction", + "getTransaction", + "subscribeToBlockHeadersWithChainLocks", + "subscribeToTransactionsWithProofs", + "subscribeToMasternodeList", + ] + ) + .or_else(|| { + match_grpc_methods!( + path, + "org.dash.platform.dapi.v0.Platform", + [ + "broadcastStateTransition", + "getIdentity", + "getIdentityKeys", + "getIdentitiesContractKeys", + "getIdentityNonce", + "getIdentityContractNonce", + "getIdentityBalance", + "getIdentitiesBalances", + "getIdentityBalanceAndRevision", + "getEvonodesProposedEpochBlocksByIds", + "getEvonodesProposedEpochBlocksByRange", + "getDataContract", + "getDataContractHistory", + "getDataContracts", + "getDocumentHistory", + "getDocuments", + "getIdentityByPublicKeyHash", + "getIdentityByNonUniquePublicKeyHash", + "waitForStateTransitionResult", + "getConsensusParams", + "getProtocolVersionUpgradeState", + "getProtocolVersionUpgradeVoteStatus", + "getEpochsInfo", + "getFinalizedEpochInfos", + "getContestedResources", + "getContestedResourceVoteState", + "getContestedResourceVotersForIdentity", + "getContestedResourceIdentityVotes", + "getVotePollsByEndDate", + "getPrefundedSpecializedBalance", + "getTotalCreditsInPlatform", + "getPathElements", + "getStatus", + "getCurrentQuorumsInfo", + "getIdentityTokenBalances", + "getIdentitiesTokenBalances", + "getIdentityTokenInfos", + "getIdentitiesTokenInfos", + "getTokenStatuses", + "getTokenDirectPurchasePrices", + "getTokenContractInfo", + "getTokenPreProgrammedDistributions", + "getTokenPerpetualDistributionLastClaim", + "getTokenTotalSupply", + "getGroupInfo", + "getGroupInfos", + "getGroupActions", + "getGroupActionSigners", + "getAddressInfo", + "getAddressesInfos", + "getAddressesTrunkState", + "getAddressesBranchState", + "getRecentAddressBalanceChanges", + "getRecentCompactedAddressBalanceChanges", + "getShieldedEncryptedNotes", + "getShieldedAnchors", + "getMostRecentShieldedAnchor", + "getShieldedPoolState", + "getShieldedNotesCount", + "getShieldedNullifiers", + ] + ) + }) + .unwrap_or("grpc_unknown") } // ---- Platform events (proxy) helpers ---- @@ -534,7 +624,9 @@ pub fn platform_events_active_sessions_dec() { } #[inline] -pub fn platform_events_command(op: &str) { +pub fn platform_events_command(op: &'static str) { + // `&'static str` keeps this label bounded by construction — request-derived + // strings cannot reach the registry (same rationale as `MethodLabel`). PLATFORM_EVENTS_COMMANDS.with_label_values(&[op]).inc(); } @@ -648,12 +740,6 @@ mod tests { assert_eq!(format!("{}", label), "GetStatusRequest"); } - #[test] - fn method_label_from_owned() { - let label = MethodLabel::from_owned("dynamic_method".to_string()); - assert_eq!(label.as_str(), "dynamic_method"); - } - #[test] fn method_label_function() { let value = 42_u32; @@ -724,20 +810,46 @@ mod tests { #[test] fn endpoint_label_grpc_with_hint() { let hint = MethodLabel::from_type_name("GetIdentity"); - let result = endpoint_label("gRPC", "/org.dash.Platform/GetIdentity", Some(&hint)); - assert_eq!(result, "GetIdentity"); + let result = endpoint_label( + "gRPC", + "/org.dash.platform.dapi.v0.Platform/getIdentity", + Some(&hint), + ); + assert_eq!(result, "org.dash.platform.dapi.v0.Platform/getIdentity"); } #[test] - fn endpoint_label_grpc_without_hint_parses_path() { - let result = endpoint_label("gRPC", "/org.dash.platform.v0.Platform/getStatus", None); - assert_eq!(result, "org.dash.platform.v0.Platform/getStatus"); + fn endpoint_label_grpc_without_hint_allowlists_path() { + let result = endpoint_label( + "gRPC", + "/org.dash.platform.dapi.v0.Platform/getStatus", + None, + ); + assert_eq!(result, "org.dash.platform.dapi.v0.Platform/getStatus"); + + let result = endpoint_label( + "gRPC", + "/org.dash.platform.dapi.v0.Core/getBlockchainStatus", + None, + ); + assert_eq!(result, "org.dash.platform.dapi.v0.Core/getBlockchainStatus"); } #[test] - fn endpoint_label_grpc_unknown_path_returns_raw() { - let result = endpoint_label("gRPC", "/", None); - assert_eq!(result, "/"); + fn endpoint_label_grpc_unknown_paths_share_one_bucket() { + for path in [ + "/", + "/org.dash.platform.dapi.v0.Core/UnknownMethod0001", + "/org.dash.platform.dapi.v0.Platform/UnknownMethod0002", + "/org.dash.platform.dapi.v0.Platform0003/getStatus", + ] { + assert_eq!(endpoint_label("gRPC", path, None), "grpc_unknown"); + } + + for suffix in 0..10_000 { + let path = format!("/org.dash.platform.dapi.v0.Core/UnknownMethod{suffix:08}"); + assert_eq!(endpoint_label("gRPC", &path, None), "grpc_unknown"); + } } #[test] @@ -750,13 +862,74 @@ mod tests { #[test] fn endpoint_label_jsonrpc_without_hint() { let result = endpoint_label("JSON-RPC", "/rpc", None); - assert_eq!(result, "/rpc"); + assert_eq!(result, "jsonrpc_unknown"); } + /// Walks dapi-grpc's FILE_DESCRIPTOR_SET and asserts every rpc of the two + /// services this server exposes resolves to a real label. Adding an rpc + /// to the protos without extending `known_grpc_endpoint` fails here + /// instead of silently degrading the new method's metrics to + /// `grpc_unknown`. #[test] - fn endpoint_label_http_returns_path() { - let result = endpoint_label("HTTP", "/health", None); - assert_eq!(result, "/health"); + fn known_grpc_endpoint_covers_every_served_rpc() { + use dapi_grpc::Message; + use prost_types::FileDescriptorSet; + + const SERVED_SERVICES: [&str; 2] = [ + "org.dash.platform.dapi.v0.Core", + "org.dash.platform.dapi.v0.Platform", + ]; + + let mut seen_services = 0; + for descriptor_bytes in [ + dapi_grpc::core::v0::FILE_DESCRIPTOR_SET, + dapi_grpc::platform::v0::FILE_DESCRIPTOR_SET, + ] { + let set = FileDescriptorSet::decode(descriptor_bytes) + .expect("dapi-grpc descriptor set should decode"); + for file in &set.file { + for service in &file.service { + let service_full = format!("{}.{}", file.package(), service.name()); + if !SERVED_SERVICES.contains(&service_full.as_str()) { + continue; + } + seen_services += 1; + assert!( + !service.method.is_empty(), + "descriptor for {service_full} lists no methods" + ); + for method in &service.method { + let path = format!("/{}/{}", service_full, method.name()); + assert_ne!( + known_grpc_endpoint(&path), + "grpc_unknown", + "{path} is served but missing from the known_grpc_endpoint \ + allowlist — add it so its metrics don't degrade to grpc_unknown" + ); + } + } + } + } + assert_eq!( + seen_services, 2, + "expected to find both served services in the descriptor sets" + ); + } + + #[test] + fn endpoint_label_http_paths_share_one_bucket() { + for path in [ + "/health", + "/org.dash.platform.dapi.v0.Platform0001", + "/org.dash.platform.dapi.v0.Core0002", + ] { + assert_eq!(endpoint_label("HTTP", path, None), "http_unknown"); + } + + for suffix in 0..10_000 { + let path = format!("/org.dash.platform.dapi.v0.Platform{suffix:08}"); + assert_eq!(endpoint_label("HTTP", &path, None), "http_unknown"); + } } // -- MetricsLayer tests -- diff --git a/packages/rs-dapi/src/server/jsonrpc.rs b/packages/rs-dapi/src/server/jsonrpc.rs index 2dd5b64bc3f..7ebacfa2477 100644 --- a/packages/rs-dapi/src/server/jsonrpc.rs +++ b/packages/rs-dapi/src/server/jsonrpc.rs @@ -59,14 +59,14 @@ async fn handle_jsonrpc_request( Json(json_rpc): Json, ) -> Response { let id = json_rpc.id.clone(); - let requested_method = json_rpc.method.clone(); + let requested_method_label = json_rpc_method_label(&json_rpc.method); let call = match state.translator.translate_request(json_rpc).await { Ok(req) => req, Err(e) => { let error_response = state.translator.error_response(e, id.clone()); return respond_with_method( - crate::metrics::MethodLabel::from_owned(requested_method), + requested_method_label, Json(serde_json::to_value(error_response).unwrap_or_default()), ); } @@ -185,18 +185,14 @@ async fn handle_jsonrpc_request( .translator .ok_response(serde_json::json!(hash.to_string()), id.clone()); respond_with_method( - crate::metrics::MethodLabel::from_owned( - "CoreClient::get_block_hash".to_string(), - ), + crate::metrics::MethodLabel::from_type_name("CoreClient::get_block_hash"), Json(serde_json::to_value(ok).unwrap_or_default()), ) } Err(e) => { let error_response = state.translator.error_response(e, id.clone()); respond_with_method( - crate::metrics::MethodLabel::from_owned( - "CoreClient::get_block_hash".to_string(), - ), + crate::metrics::MethodLabel::from_type_name("CoreClient::get_block_hash"), Json(serde_json::to_value(error_response).unwrap_or_default()), ) } @@ -205,8 +201,38 @@ async fn handle_jsonrpc_request( } } +fn json_rpc_method_label(method: &str) -> crate::metrics::MethodLabel { + let label = match method { + "getStatus" => "getStatus", + "getBestBlockHash" => "getBestBlockHash", + "getBlockHash" => "getBlockHash", + "sendRawTransaction" => "sendRawTransaction", + _ => "jsonrpc_unknown", + }; + + crate::metrics::MethodLabel::from_type_name(label) +} + fn respond_with_method(method: crate::metrics::MethodLabel, body: Json) -> Response { let mut response = body.into_response(); crate::metrics::attach_method_label(response.extensions_mut(), method); response } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_rpc_method_labels_are_allowlisted() { + assert_eq!(json_rpc_method_label("getStatus").as_str(), "getStatus"); + assert_eq!( + json_rpc_method_label("unsupported_0001").as_str(), + "jsonrpc_unknown" + ); + assert_eq!( + json_rpc_method_label("unsupported_0002").as_str(), + "jsonrpc_unknown" + ); + } +}