From a78d5c5ecd355857be5cf76f40a33a3fb6be6785 Mon Sep 17 00:00:00 2001 From: Dimitris Sarlis Date: Wed, 27 Sep 2023 12:49:34 +0000 Subject: [PATCH] feat: EXC-1469: Expose subnet metrics through the http api --- Cargo.lock | 2 + rs/http_endpoints/public/BUILD.bazel | 2 + rs/http_endpoints/public/Cargo.toml | 4 +- rs/http_endpoints/public/src/call.rs | 17 +- rs/http_endpoints/public/src/common.rs | 14 +- rs/http_endpoints/public/src/lib.rs | 75 ++- rs/http_endpoints/public/src/query.rs | 21 +- rs/http_endpoints/public/src/read_state.rs | 572 +----------------- .../public/src/read_state/canister.rs | 554 +++++++++++++++++ .../public/src/read_state/subnet.rs | 317 ++++++++++ rs/http_endpoints/public/tests/test.rs | 2 +- 11 files changed, 988 insertions(+), 592 deletions(-) create mode 100644 rs/http_endpoints/public/src/read_state/canister.rs create mode 100644 rs/http_endpoints/public/src/read_state/subnet.rs diff --git a/Cargo.lock b/Cargo.lock index 8d8960e5926..e5173d429c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7349,12 +7349,14 @@ dependencies = [ "maplit", "mockall 0.11.4", "native-tls", + "phantom_newtype", "pretty_assertions 0.7.2", "prometheus 0.12.0", "proptest", "prost", "rand 0.8.5", "serde", + "serde_bytes", "serde_cbor", "slog", "strum 0.24.1", diff --git a/rs/http_endpoints/public/BUILD.bazel b/rs/http_endpoints/public/BUILD.bazel index 80cdf762e1a..81004a1a012 100644 --- a/rs/http_endpoints/public/BUILD.bazel +++ b/rs/http_endpoints/public/BUILD.bazel @@ -22,6 +22,7 @@ DEPENDENCIES = [ "//rs/registry/provisional_whitelist", "//rs/registry/subnet_type", "//rs/replicated_state", + "//rs/phantom_newtype", "//rs/types/error_types", "//rs/types/types", "//rs/validator", @@ -75,6 +76,7 @@ DEV_DEPENDENCIES = [ "@crate_index//:maplit", "@crate_index//:pretty_assertions", "@crate_index//:proptest", + "@crate_index//:serde_bytes", "@crate_index//:tower-test", ] diff --git a/rs/http_endpoints/public/Cargo.toml b/rs/http_endpoints/public/Cargo.toml index 5849212b942..24611622f36 100644 --- a/rs/http_endpoints/public/Cargo.toml +++ b/rs/http_endpoints/public/Cargo.toml @@ -10,7 +10,7 @@ byte-unit = "4.0.14" crossbeam = "0.8.2" hex = "0.4.2" http = "0.2.5" -futures ={ workspace = true } +futures = { workspace = true } futures-util = "0.3.13" hyper = { version = "0.14.18", features = ["full"] } hyper-tls = "0.5.0" @@ -35,6 +35,7 @@ ic-replicated-state = { path = "../../replicated_state" } ic-types = { path = "../../types/types" } ic-validator = { path = "../../validator" } native-tls = { version = "0.2.7", features = ["alpn"] } +phantom_newtype = { path = "../../phantom_newtype" } prometheus = { version = "0.12.0", features = ["process"] } prost = { workspace = true } rand = "0.8.3" @@ -70,6 +71,7 @@ mockall = "0.11.4" maplit = "1.0.2" pretty_assertions = "0.7.1" proptest = "1.0.0" +serde_bytes = "0.11" tower-test = "0.4.0" [features] diff --git a/rs/http_endpoints/public/src/call.rs b/rs/http_endpoints/public/src/call.rs index c0710a808c1..6a35fef51db 100644 --- a/rs/http_endpoints/public/src/call.rs +++ b/rs/http_endpoints/public/src/call.rs @@ -3,7 +3,7 @@ use crate::{ body::BodyReceiverLayer, common::{ - get_cors_headers, make_plaintext_response, make_response, remove_effective_canister_id, + get_cors_headers, make_plaintext_response, make_response, remove_effective_principal_id, }, metrics::LABEL_UNKNOWN, types::ApiReqType, @@ -174,8 +174,8 @@ impl Service>> for CallService { } }; - let effective_canister_id = match remove_effective_canister_id(&mut parts) { - Ok(canister_id) => canister_id, + let effective_principal_id = match remove_effective_principal_id(&mut parts) { + Ok(principal_id) => principal_id, Err(res) => { error!( self.log, @@ -185,6 +185,17 @@ impl Service>> for CallService { } }; + let effective_canister_id = match CanisterId::new(effective_principal_id) { + Ok(canister_id) => canister_id, + Err(_) => { + let res = make_plaintext_response( + StatusCode::BAD_REQUEST, + format!("Invalid canister id: {}", effective_principal_id), + ); + return Box::pin(async move { Ok(res) }); + } + }; + // Reject requests where `canister_id` != `effective_canister_id` for non mgmt canister calls. // This needs to be enforced because boundary nodes block access based on the `effective_canister_id` // in the url and the replica processes the request based on the `canister_id`. diff --git a/rs/http_endpoints/public/src/common.rs b/rs/http_endpoints/public/src/common.rs index 8094424eba0..816022f97b3 100644 --- a/rs/http_endpoints/public/src/common.rs +++ b/rs/http_endpoints/public/src/common.rs @@ -8,9 +8,9 @@ use ic_interfaces_registry::RegistryClient; use ic_logger::{info, warn, ReplicaLogger}; use ic_registry_client_helpers::crypto::CryptoRegistry; use ic_replicated_state::ReplicatedState; -use ic_types::CanisterId; use ic_types::{ - crypto::threshold_sig::ThresholdSigPublicKey, messages::MessageId, RegistryVersion, SubnetId, + crypto::threshold_sig::ThresholdSigPublicKey, messages::MessageId, PrincipalId, + RegistryVersion, SubnetId, }; use ic_validator::RequestValidationError; use serde::Serialize; @@ -213,14 +213,14 @@ pub(crate) async fn get_latest_certified_state( /// Remove the effective canister id from the request parts. /// The effective canister id is added to the request during routing by looking at the url. /// Returns an BAD_REQUEST response if the effective canister id is not found in the request parts. -pub(crate) fn remove_effective_canister_id( +pub(crate) fn remove_effective_principal_id( parts: &mut Parts, -) -> Result> { - match parts.extensions.remove::() { - Some(canister_id) => Ok(canister_id), +) -> Result> { + match parts.extensions.remove::() { + Some(principal_id) => Ok(principal_id), _ => Err(make_plaintext_response( StatusCode::INTERNAL_SERVER_ERROR, - "Failed to get effective canister id from request. This is a bug.".to_string(), + "Failed to get effective principal id from request. This is a bug.".to_string(), )), } } diff --git a/rs/http_endpoints/public/src/lib.rs b/rs/http_endpoints/public/src/lib.rs index 8a8008b4b96..b059e2ea314 100644 --- a/rs/http_endpoints/public/src/lib.rs +++ b/rs/http_endpoints/public/src/lib.rs @@ -36,7 +36,7 @@ use crate::{ }, pprof::{PprofFlamegraphService, PprofHomeService, PprofProfileService}, query::QueryService, - read_state::ReadStateService, + read_state::{canister::CanisterReadStateService, subnet::SubnetReadStateService}, state_reader_executor::StateReaderExecutor, status::StatusService, types::*, @@ -80,7 +80,7 @@ use ic_types::{ HttpReadStateResponse, HttpRequestEnvelope, QueryResponseHash, ReplicaHealthStatus, }, time::expiry_time_from_now, - CanisterId, NodeId, SubnetId, + NodeId, PrincipalId, SubnetId, }; use metrics::{HttpHandlerMetrics, LABEL_UNKNOWN}; use rand::Rng; @@ -123,7 +123,8 @@ struct HttpHandler { catchup_service: EndpointService, dashboard_service: EndpointService, status_service: EndpointService, - read_state_service: EndpointService, + canister_read_state_service: EndpointService, + subnet_read_state_service: EndpointService, pprof_home_service: EndpointService, pprof_profile_service: EndpointService, pprof_flamegraph_service: EndpointService, @@ -312,7 +313,7 @@ pub fn start_server( Arc::clone(®istry_client), query_execution_service, ); - let read_state_service = ReadStateService::new_service( + let canister_read_state_service = CanisterReadStateService::new_service( config.clone(), log.clone(), metrics.clone(), @@ -327,6 +328,14 @@ pub fn start_server( ), Arc::clone(®istry_client), ); + let subnet_read_state_service = SubnetReadStateService::new_service( + config.clone(), + log.clone(), + metrics.clone(), + Arc::clone(&health_status), + Arc::clone(&delegation_from_nns), + state_reader_executor.clone(), + ); let status_service = StatusService::new_service( config.clone(), log.clone(), @@ -380,7 +389,8 @@ pub fn start_server( status_service, catchup_service, dashboard_service, - read_state_service, + canister_read_state_service, + subnet_read_state_service, pprof_home_service, pprof_profile_service, pprof_flamegraph_service, @@ -647,7 +657,8 @@ async fn make_router( let status_service = http_handler.status_service.clone(); let catch_up_package_service = http_handler.catchup_service.clone(); let dashboard_service = http_handler.dashboard_service.clone(); - let read_state_service = http_handler.read_state_service.clone(); + let canister_read_state_service = http_handler.canister_read_state_service.clone(); + let subnet_read_state_service = http_handler.subnet_read_state_service.clone(); let pprof_home_service = http_handler.pprof_home_service.clone(); let pprof_profile_service = http_handler.pprof_profile_service.clone(); let pprof_flamegraph_service = http_handler.pprof_flamegraph_service.clone(); @@ -678,19 +689,47 @@ async fn make_router( // Check the path let path = req.uri().path(); - let (svc, effective_canister_id) = + let (svc, effective_principal_id) = match *path.split('/').collect::>().as_slice() { ["", "api", "v2", "canister", effective_canister_id, "call"] => { timer.set_label(LABEL_REQUEST_TYPE, ApiReqType::Call.into()); - (call_service, Some(effective_canister_id)) + ( + call_service, + Some( + PrincipalId::from_str(effective_canister_id) + .map_err(|err| (effective_canister_id, err.to_string())), + ), + ) } ["", "api", "v2", "canister", effective_canister_id, "query"] => { timer.set_label(LABEL_REQUEST_TYPE, ApiReqType::Query.into()); - (query_service, Some(effective_canister_id)) + ( + query_service, + Some( + PrincipalId::from_str(effective_canister_id) + .map_err(|err| (effective_canister_id, err.to_string())), + ), + ) } ["", "api", "v2", "canister", effective_canister_id, "read_state"] => { timer.set_label(LABEL_REQUEST_TYPE, ApiReqType::ReadState.into()); - (read_state_service, Some(effective_canister_id)) + ( + canister_read_state_service, + Some( + PrincipalId::from_str(effective_canister_id) + .map_err(|err| (effective_canister_id, err.to_string())), + ), + ) + } + ["", "api", "v2", "subnet", subnet_id, "read_state"] => { + timer.set_label(LABEL_REQUEST_TYPE, ApiReqType::ReadState.into()); + ( + subnet_read_state_service, + Some( + PrincipalId::from_str(subnet_id) + .map_err(|err| (subnet_id, err.to_string())), + ), + ) } ["", "_", "catch_up_package"] => { timer.set_label(LABEL_REQUEST_TYPE, ApiReqType::CatchUpPackage.into()); @@ -708,19 +747,19 @@ async fn make_router( } }; - // If url contains effective canister id we attach it to the request. - if let Some(effective_canister_id) = effective_canister_id { - match CanisterId::from_str(effective_canister_id) { - Ok(effective_canister_id) => { - req.extensions_mut().insert(effective_canister_id); + // If url contains effective principal id we attach it to the request. + if let Some(effective_principal_id) = effective_principal_id { + match effective_principal_id { + Ok(id) => { + req.extensions_mut().insert(id); } - Err(e) => { + Err((id, e)) => { return ( make_plaintext_response( StatusCode::BAD_REQUEST, format!( - "Malformed request: Invalid efffective canister id {}: {}", - effective_canister_id, e + "Malformed request: Invalid efffective principal id {}: {}", + id, e ), ), timer, diff --git a/rs/http_endpoints/public/src/query.rs b/rs/http_endpoints/public/src/query.rs index 198734c601f..51e0636441c 100644 --- a/rs/http_endpoints/public/src/query.rs +++ b/rs/http_endpoints/public/src/query.rs @@ -2,7 +2,7 @@ use crate::{ body::BodyReceiverLayer, - common::{cbor_response, make_plaintext_response, remove_effective_canister_id}, + common::{cbor_response, make_plaintext_response, remove_effective_principal_id}, metrics::LABEL_UNKNOWN, types::ApiReqType, validator_executor::ValidatorExecutor, @@ -25,7 +25,7 @@ use ic_types::{ HttpRequestEnvelope, HttpSignedQueryResponse, NodeSignature, QueryResponseHash, SignedRequestBytes, UserQuery, }, - NodeId, + CanisterId, NodeId, }; use std::convert::{Infallible, TryFrom}; use std::future::Future; @@ -140,12 +140,23 @@ impl Service>> for QueryService { } }; - let effective_canister_id = match remove_effective_canister_id(&mut parts) { - Ok(canister_id) => canister_id, + let effective_principal_id = match remove_effective_principal_id(&mut parts) { + Ok(principal_id) => principal_id, Err(res) => { error!( self.log, - "Effective canister ID is not attached to query request. This is a bug." + "Effective canister ID is not attached to call request. This is a bug." + ); + return Box::pin(async move { Ok(res) }); + } + }; + + let effective_canister_id = match CanisterId::new(effective_principal_id) { + Ok(canister_id) => canister_id, + Err(_) => { + let res = make_plaintext_response( + StatusCode::BAD_REQUEST, + format!("Invalid canister id: {}", effective_principal_id), ); return Box::pin(async move { Ok(res) }); } diff --git a/rs/http_endpoints/public/src/read_state.rs b/rs/http_endpoints/public/src/read_state.rs index 10566b6076e..61066b02db9 100644 --- a/rs/http_endpoints/public/src/read_state.rs +++ b/rs/http_endpoints/public/src/read_state.rs @@ -1,576 +1,34 @@ //! Module that deals with requests to /api/v2/canister/.../read_state -use crate::{ - body::BodyReceiverLayer, - common::{cbor_response, into_cbor, make_plaintext_response, remove_effective_canister_id}, - metrics::LABEL_UNKNOWN, - state_reader_executor::StateReaderExecutor, - types::ApiReqType, - validator_executor::ValidatorExecutor, - EndpointService, HttpError, HttpHandlerMetrics, ReplicaHealthStatus, -}; -use crossbeam::atomic::AtomicCell; -use http::Request; -use hyper::{Body, Response, StatusCode}; -use ic_config::http_handler::Config; -use ic_crypto_tree_hash::{sparse_labeled_tree_from_paths, Label, Path, TooLongPathError}; -use ic_interfaces_registry::RegistryClient; -use ic_logger::{error, ReplicaLogger}; -use ic_replicated_state::{canister_state::execution_state::CustomSectionType, ReplicatedState}; -use ic_types::{ - messages::{ - Blob, Certificate, CertificateDelegation, HttpReadStateContent, HttpReadStateResponse, - HttpRequest, HttpRequestEnvelope, MessageId, ReadState, SignedRequestBytes, - EXPECTED_MESSAGE_ID_LENGTH, - }, - CanisterId, UserId, -}; -use ic_validator::CanisterIdSet; -use std::convert::{Infallible, TryFrom}; -use std::future::Future; -use std::pin::Pin; -use std::sync::{Arc, RwLock}; -use std::task::{Context, Poll}; -use tower::{ - limit::concurrency::GlobalConcurrencyLimitLayer, util::BoxCloneService, Service, ServiceBuilder, -}; +use crate::HttpError; +use hyper::StatusCode; +use ic_types::PrincipalId; -#[derive(Clone)] -pub(crate) struct ReadStateService { - log: ReplicaLogger, - metrics: HttpHandlerMetrics, - health_status: Arc>, - delegation_from_nns: Arc>>, - state_reader_executor: StateReaderExecutor, - validator_executor: ValidatorExecutor, - registry_client: Arc, -} - -impl ReadStateService { - #[allow(clippy::too_many_arguments)] - pub(crate) fn new_service( - config: Config, - log: ReplicaLogger, - metrics: HttpHandlerMetrics, - health_status: Arc>, - delegation_from_nns: Arc>>, - state_reader_executor: StateReaderExecutor, - validator_executor: ValidatorExecutor, - registry_client: Arc, - ) -> EndpointService { - let base_service = Self { - log, - metrics, - health_status, - delegation_from_nns, - state_reader_executor, - validator_executor, - registry_client, - }; - let base_service = BoxCloneService::new( - ServiceBuilder::new() - .layer(GlobalConcurrencyLimitLayer::new( - config.max_read_state_concurrent_requests, - )) - .service(base_service), - ); - BoxCloneService::new( - ServiceBuilder::new() - .layer(BodyReceiverLayer::new(&config)) - .service(base_service), - ) - } -} - -impl Service>> for ReadStateService { - type Response = Response; - type Error = Infallible; - #[allow(clippy::type_complexity)] - type Future = Pin> + Send>>; - - fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - - fn call(&mut self, request: Request>) -> Self::Future { - self.metrics - .request_body_size_bytes - .with_label_values(&[ApiReqType::ReadState.into(), LABEL_UNKNOWN]) - .observe(request.body().len() as f64); - - if self.health_status.load() != ReplicaHealthStatus::Healthy { - let res = make_plaintext_response( - StatusCode::SERVICE_UNAVAILABLE, - format!( - "Replica is unhealthy: {}. Check the /api/v2/status for more information.", - self.health_status.load(), - ), - ); - return Box::pin(async move { Ok(res) }); - } - let (mut parts, body) = request.into_parts(); - // By removing the canister id we get ownership and avoid having to clone it when creating the future. - let effective_canister_id = match remove_effective_canister_id(&mut parts) { - Ok(canister_id) => canister_id, - Err(res) => { - error!( - self.log, - "Effective canister ID is not attached to read state request. This is a bug." - ); - return Box::pin(async move { Ok(res) }); - } - }; - - let delegation_from_nns = self.delegation_from_nns.read().unwrap().clone(); - - let request = match >::try_from( - &SignedRequestBytes::from(body), - ) { - Ok(request) => request, - Err(e) => { - let res = make_plaintext_response( - StatusCode::BAD_REQUEST, - format!("Could not parse body as read request: {}", e), - ); - return Box::pin(async move { Ok(res) }); - } - }; - - // Convert the message to a strongly-typed struct. - let request = match HttpRequest::::try_from(request) { - Ok(request) => request, - Err(e) => { - let res = make_plaintext_response( - StatusCode::BAD_REQUEST, - format!("Malformed request: {:?}", e), - ); - return Box::pin(async move { Ok(res) }); - } - }; - - let read_state = request.content().clone(); - let registry_version = self.registry_client.get_latest_version(); - let state_reader_executor = self.state_reader_executor.clone(); - let validator_executor = self.validator_executor.clone(); - let metrics = self.metrics.clone(); - Box::pin(async move { - let targets_fut = - validator_executor.validate_request(request.clone(), registry_version); - - let targets = match targets_fut.await { - Ok(targets) => targets, - Err(http_err) => { - let res = make_plaintext_response(http_err.status, http_err.message); - return Ok(res); - } - }; - let make_service_unavailable_response = || { - make_plaintext_response( - StatusCode::SERVICE_UNAVAILABLE, - "Certified state is not available yet. Please try again...".to_string(), - ) - }; - let certified_state_reader = - match state_reader_executor.get_certified_state_reader().await { - Ok(Some(reader)) => reader, - Ok(None) => return Ok(make_service_unavailable_response()), - Err(HttpError { status, message }) => { - return Ok(make_plaintext_response(status, message)) - } - }; - - // Verify authorization for requested paths. - if let Err(HttpError { status, message }) = verify_paths( - certified_state_reader.get_state(), - &read_state.source, - &read_state.paths, - &targets, - effective_canister_id, - ) { - return Ok(make_plaintext_response(status, message)); - } - - // Create labeled tree. This may be an expensive operation and by - // creating the labeled tree after verifying the paths we know that - // the depth is max 4. - // Always add "time" to the paths even if not explicitly requested. - let mut paths: Vec = read_state.paths; - paths.push(Path::from(Label::from("time"))); - let labeled_tree = match sparse_labeled_tree_from_paths(&paths) { - Ok(tree) => tree, - Err(TooLongPathError) => { - let res = make_plaintext_response( - StatusCode::BAD_REQUEST, - "Failed to parse requested paths: path is too long.".to_string(), - ); - return Ok(res); - } - }; - - let (tree, certification) = - match certified_state_reader.read_certified_state(&labeled_tree) { - Some(r) => r, - None => return Ok(make_service_unavailable_response()), - }; +pub(crate) mod canister; +pub(crate) mod subnet; - let signature = certification.signed.signature.signature.get().0; - let res = HttpReadStateResponse { - certificate: Blob(into_cbor(&Certificate { - tree, - signature: Blob(signature), - delegation: delegation_from_nns, - })), - }; - let (resp, body_size) = cbor_response(&res); - metrics - .response_body_size_bytes - .with_label_values(&[ApiReqType::ReadState.into()]) - .observe(body_size as f64); - Ok(resp) - }) - } -} - -// Verifies that the `user` is authorized to retrieve the `paths` requested. -fn verify_paths( - state: &ReplicatedState, - user: &UserId, - paths: &[Path], - targets: &CanisterIdSet, - effective_canister_id: CanisterId, -) -> Result<(), HttpError> { - let mut request_status_id: Option = None; - - // Convert the paths to slices to make it easier to match below. - let paths: Vec> = paths - .iter() - .map(|path| path.iter().map(|label| label.as_bytes()).collect()) - .collect(); - - for path in paths { - match path.as_slice() { - [b"time"] => {} - [b"canister", canister_id, b"controllers" | b"module_hash"] => { - let canister_id = parse_canister_id(canister_id)?; - verify_canister_ids(&canister_id, &effective_canister_id)?; - } - [b"canister", canister_id, b"metadata", name] => { - let name = String::from_utf8(Vec::from(*name)).map_err(|err| HttpError { - status: StatusCode::BAD_REQUEST, - message: format!("Could not parse the custom section name: {}.", err), - })?; - - // Get canister id from byte slice. - let canister_id = parse_canister_id(canister_id)?; - // Verify that canister id and effective canister id match. - verify_canister_ids(&canister_id, &effective_canister_id)?; - can_read_canister_metadata(user, &canister_id, &name, state)? - } - [b"subnet"] => {} - [b"subnet", _subnet_id] => {} - [b"subnet", _subnet_id, b"public_key" | b"canister_ranges" | b"node"] => {} - [b"subnet", _subnet_id, b"node", _node_id] => {} - [b"subnet", _subnet_id, b"node", _node_id, b"public_key"] => {} - [b"request_status", request_id] - | [b"request_status", request_id, b"status" | b"reply" | b"reject_code" | b"reject_message" | b"error_code"] => - { - // Verify that the request was signed by the same user. - if let Ok(message_id) = MessageId::try_from(*request_id) { - if let Some(request_status_id) = request_status_id { - if request_status_id != message_id { - return Err(HttpError { - status: StatusCode::BAD_REQUEST, - message: - "Can only request a single request ID in request_status paths." - .to_string(), - }); - } - } - - let ingress_status = state.get_ingress_status(&message_id); - if let Some(ingress_user_id) = ingress_status.user_id() { - if let Some(receiver) = ingress_status.receiver() { - if ingress_user_id != *user || !targets.contains(&receiver) { - return Err(HttpError { - status: StatusCode::FORBIDDEN, - message: - "Request IDs must be for requests signed by the caller." - .to_string(), - }); - } - } - } - - request_status_id = Some(message_id); - } else { - return Err(HttpError { - status: StatusCode::BAD_REQUEST, - message: format!( - "Request IDs must be {} bytes in length.", - EXPECTED_MESSAGE_ID_LENGTH - ), - }); - } - } - _ => { - // All other paths are unsupported. - return Err(HttpError { - status: StatusCode::NOT_FOUND, - message: "Invalid path requested.".to_string(), - }); - } - } - } - - Ok(()) -} - -fn parse_canister_id(canister_id: &[u8]) -> Result { - match CanisterId::try_from(canister_id) { - Ok(canister_id) => Ok(canister_id), +fn parse_principal_id(principal_id: &[u8]) -> Result { + match PrincipalId::try_from(principal_id) { + Ok(principal_id) => Ok(principal_id), Err(err) => Err(HttpError { status: StatusCode::BAD_REQUEST, - message: format!("Could not parse Canister ID: {}.", err), + message: format!("Could not parse principal ID: {}.", err), }), } } -fn verify_canister_ids( - canister_id: &CanisterId, - effective_canister_id: &CanisterId, +fn verify_principal_ids( + principal_id: &PrincipalId, + effective_principal_id: &PrincipalId, ) -> Result<(), HttpError> { - if canister_id != effective_canister_id { + if principal_id != effective_principal_id { return Err(HttpError { status: StatusCode::BAD_REQUEST, message: format!( - "Effective canister id in URL {} does not match requested canister id: {}.", - effective_canister_id, canister_id + "Effective principal id in URL {} does not match requested principal id: {}.", + effective_principal_id, principal_id ), }); } Ok(()) } - -fn can_read_canister_metadata( - user: &UserId, - canister_id: &CanisterId, - custom_section_name: &str, - state: &ReplicatedState, -) -> Result<(), HttpError> { - let canister = match state.canister_states.get(canister_id) { - Some(canister) => canister, - None => return Ok(()), - }; - - match &canister.execution_state { - Some(execution_state) => { - let custom_section = match execution_state - .metadata - .get_custom_section(custom_section_name) - { - Some(section) => section, - None => return Ok(()), - }; - - // Only the controller can request this custom section. - if custom_section.visibility() == CustomSectionType::Private - && !canister.system_state.controllers.contains(&user.get()) - { - return Err(HttpError { - status: StatusCode::FORBIDDEN, - message: format!( - "Custom section {:.100} can only be requested by the controllers of the canister.", - custom_section_name - ), - }); - } - - Ok(()) - } - None => Ok(()), - } -} - -#[cfg(test)] -mod test { - use crate::{ - common::test::{array, assert_cbor_ser_equal, bytes, int}, - read_state::{can_read_canister_metadata, verify_paths}, - HttpError, - }; - use hyper::StatusCode; - use ic_crypto_tree_hash::{Digest, Label, MixedHashTree, Path}; - use ic_registry_subnet_type::SubnetType; - use ic_replicated_state::{CanisterQueues, ReplicatedState, SystemMetadata}; - use ic_test_utilities::{ - mock_time, - state::insert_dummy_canister, - types::ids::{canister_test_id, subnet_test_id, user_test_id}, - }; - use ic_types::batch::ReceivedEpochStats; - use ic_validator::CanisterIdSet; - use std::collections::BTreeMap; - - #[test] - fn encoding_read_state_tree_empty() { - let tree = MixedHashTree::Empty; - assert_cbor_ser_equal(&tree, array(vec![int(0)])); - } - - #[test] - fn encoding_read_state_tree_leaf() { - let tree = MixedHashTree::Leaf(vec![1, 2, 3]); - assert_cbor_ser_equal(&tree, array(vec![int(3), bytes(&[1, 2, 3])])); - } - - #[test] - fn encoding_read_state_tree_pruned() { - let tree = MixedHashTree::Pruned(Digest([1; 32])); - assert_cbor_ser_equal(&tree, array(vec![int(4), bytes(&[1; 32])])); - } - - #[test] - fn encoding_read_state_tree_fork() { - let tree = MixedHashTree::Fork(Box::new(( - MixedHashTree::Leaf(vec![1, 2, 3]), - MixedHashTree::Leaf(vec![4, 5, 6]), - ))); - assert_cbor_ser_equal( - &tree, - array(vec![ - int(1), - array(vec![int(3), bytes(&[1, 2, 3])]), - array(vec![int(3), bytes(&[4, 5, 6])]), - ]), - ); - } - - #[test] - fn encoding_read_state_tree_mixed() { - let tree = MixedHashTree::Fork(Box::new(( - MixedHashTree::Labeled( - Label::from(vec![1, 2, 3]), - Box::new(MixedHashTree::Pruned(Digest([2; 32]))), - ), - MixedHashTree::Leaf(vec![4, 5, 6]), - ))); - assert_cbor_ser_equal( - &tree, - array(vec![ - int(1), - array(vec![ - int(2), - bytes(&[1, 2, 3]), - array(vec![int(4), bytes(&[2; 32])]), - ]), - array(vec![int(3), bytes(&[4, 5, 6])]), - ]), - ); - } - - #[test] - fn user_can_read_canister_metadata() { - let canister_id = canister_test_id(100); - let controller = user_test_id(24); - let non_controller = user_test_id(20); - - let mut state = ReplicatedState::new(subnet_test_id(1), SubnetType::Application); - insert_dummy_canister(&mut state, canister_id, controller.get()); - - let public_name = "dummy"; - // Controller can read the public custom section - assert!(can_read_canister_metadata(&controller, &canister_id, public_name, &state).is_ok()); - - // Non-controller can read public custom section - assert!( - can_read_canister_metadata(&non_controller, &canister_id, public_name, &state).is_ok() - ); - - let private_name = "candid"; - // Controller can read private custom section - assert!( - can_read_canister_metadata(&controller, &canister_id, private_name, &state).is_ok() - ); - } - - #[test] - fn user_cannot_read_canister_metadata() { - let canister_id = canister_test_id(100); - let controller = user_test_id(24); - let non_controller = user_test_id(20); - - let mut state = ReplicatedState::new(subnet_test_id(1), SubnetType::Application); - insert_dummy_canister(&mut state, canister_id, controller.get()); - - // Non-controller cannot read private custom section named `candid`. - assert_eq!( - can_read_canister_metadata(&non_controller, &canister_id, "candid", &state), - Err(HttpError { - status: StatusCode::FORBIDDEN, - message: "Custom section candid can only be requested by the controllers of the canister." - .to_string() - }) - ); - - // Non existent public custom section. - assert_eq!( - can_read_canister_metadata(&non_controller, &canister_id, "unknown-name", &state), - Ok(()) - ); - } - - #[test] - fn test_verify_path() { - let subnet_id = subnet_test_id(1); - let mut metadata = SystemMetadata::new(subnet_id, SubnetType::Application); - metadata.batch_time = mock_time(); - let state = ReplicatedState::new_from_checkpoint( - BTreeMap::new(), - metadata, - CanisterQueues::default(), - ReceivedEpochStats::default(), - ); - assert_eq!( - verify_paths( - &state, - &user_test_id(1), - &[Path::from(Label::from("time"))], - &CanisterIdSet::all(), - canister_test_id(1), - ), - Ok(()) - ); - assert_eq!( - verify_paths( - &state, - &user_test_id(1), - &[ - Path::new(vec![ - Label::from("request_status"), - [0; 32].into(), - Label::from("status") - ]), - Path::new(vec![ - Label::from("request_status"), - [0; 32].into(), - Label::from("reply") - ]) - ], - &CanisterIdSet::all(), - canister_test_id(1), - ), - Ok(()) - ); - assert!(verify_paths( - &state, - &user_test_id(1), - &[ - Path::new(vec![Label::from("request_status"), [0; 32].into()]), - Path::new(vec![Label::from("request_status"), [1; 32].into()]) - ], - &CanisterIdSet::all(), - canister_test_id(1), - ) - .is_err()); - } -} diff --git a/rs/http_endpoints/public/src/read_state/canister.rs b/rs/http_endpoints/public/src/read_state/canister.rs new file mode 100644 index 00000000000..2e7d210a7f4 --- /dev/null +++ b/rs/http_endpoints/public/src/read_state/canister.rs @@ -0,0 +1,554 @@ +use super::{parse_principal_id, verify_principal_ids}; +use crate::{ + body::BodyReceiverLayer, + common::{cbor_response, into_cbor, make_plaintext_response, remove_effective_principal_id}, + metrics::LABEL_UNKNOWN, + state_reader_executor::StateReaderExecutor, + types::ApiReqType, + validator_executor::ValidatorExecutor, + EndpointService, HttpError, HttpHandlerMetrics, ReplicaHealthStatus, +}; +use crossbeam::atomic::AtomicCell; +use http::Request; +use hyper::{Body, Response, StatusCode}; +use ic_config::http_handler::Config; +use ic_crypto_tree_hash::{sparse_labeled_tree_from_paths, Label, Path, TooLongPathError}; +use ic_interfaces_registry::RegistryClient; +use ic_logger::{error, ReplicaLogger}; +use ic_replicated_state::{canister_state::execution_state::CustomSectionType, ReplicatedState}; +use ic_types::{ + messages::{ + Blob, Certificate, CertificateDelegation, HttpReadStateContent, HttpReadStateResponse, + HttpRequest, HttpRequestEnvelope, MessageId, ReadState, SignedRequestBytes, + EXPECTED_MESSAGE_ID_LENGTH, + }, + CanisterId, PrincipalId, UserId, +}; +use ic_validator::CanisterIdSet; +use std::convert::{Infallible, TryFrom}; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, RwLock}; +use std::task::{Context, Poll}; +use tower::{ + limit::concurrency::GlobalConcurrencyLimitLayer, util::BoxCloneService, Service, ServiceBuilder, +}; + +#[derive(Clone)] +pub(crate) struct CanisterReadStateService { + log: ReplicaLogger, + metrics: HttpHandlerMetrics, + health_status: Arc>, + delegation_from_nns: Arc>>, + state_reader_executor: StateReaderExecutor, + validator_executor: ValidatorExecutor, + registry_client: Arc, +} + +impl CanisterReadStateService { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_service( + config: Config, + log: ReplicaLogger, + metrics: HttpHandlerMetrics, + health_status: Arc>, + delegation_from_nns: Arc>>, + state_reader_executor: StateReaderExecutor, + validator_executor: ValidatorExecutor, + registry_client: Arc, + ) -> EndpointService { + let base_service = Self { + log, + metrics, + health_status, + delegation_from_nns, + state_reader_executor, + validator_executor, + registry_client, + }; + let base_service = BoxCloneService::new( + ServiceBuilder::new() + .layer(GlobalConcurrencyLimitLayer::new( + config.max_read_state_concurrent_requests, + )) + .service(base_service), + ); + BoxCloneService::new( + ServiceBuilder::new() + .layer(BodyReceiverLayer::new(&config)) + .service(base_service), + ) + } +} + +impl Service>> for CanisterReadStateService { + type Response = Response; + type Error = Infallible; + #[allow(clippy::type_complexity)] + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, request: Request>) -> Self::Future { + self.metrics + .request_body_size_bytes + .with_label_values(&[ApiReqType::ReadState.into(), LABEL_UNKNOWN]) + .observe(request.body().len() as f64); + + if self.health_status.load() != ReplicaHealthStatus::Healthy { + let res = make_plaintext_response( + StatusCode::SERVICE_UNAVAILABLE, + format!( + "Replica is unhealthy: {}. Check the /api/v2/status for more information.", + self.health_status.load(), + ), + ); + return Box::pin(async move { Ok(res) }); + } + let (mut parts, body) = request.into_parts(); + // By removing the principal id we get ownership and avoid having to clone it when creating the future. + let effective_principal_id = match remove_effective_principal_id(&mut parts) { + Ok(canister_id) => canister_id, + Err(res) => { + error!( + self.log, + "Effective canister ID is not attached to read state request. This is a bug." + ); + return Box::pin(async move { Ok(res) }); + } + }; + + let delegation_from_nns = self.delegation_from_nns.read().unwrap().clone(); + + let request = match >::try_from( + &SignedRequestBytes::from(body), + ) { + Ok(request) => request, + Err(e) => { + let res = make_plaintext_response( + StatusCode::BAD_REQUEST, + format!("Could not parse body as read request: {}", e), + ); + return Box::pin(async move { Ok(res) }); + } + }; + + // Convert the message to a strongly-typed struct. + let request = match HttpRequest::::try_from(request) { + Ok(request) => request, + Err(e) => { + let res = make_plaintext_response( + StatusCode::BAD_REQUEST, + format!("Malformed request: {:?}", e), + ); + return Box::pin(async move { Ok(res) }); + } + }; + + let read_state = request.content().clone(); + let registry_version = self.registry_client.get_latest_version(); + let state_reader_executor = self.state_reader_executor.clone(); + let validator_executor = self.validator_executor.clone(); + let metrics = self.metrics.clone(); + Box::pin(async move { + let targets_fut = + validator_executor.validate_request(request.clone(), registry_version); + + let targets = match targets_fut.await { + Ok(targets) => targets, + Err(http_err) => { + let res = make_plaintext_response(http_err.status, http_err.message); + return Ok(res); + } + }; + let make_service_unavailable_response = || { + make_plaintext_response( + StatusCode::SERVICE_UNAVAILABLE, + "Certified state is not available yet. Please try again...".to_string(), + ) + }; + let certified_state_reader = + match state_reader_executor.get_certified_state_reader().await { + Ok(Some(reader)) => reader, + Ok(None) => return Ok(make_service_unavailable_response()), + Err(HttpError { status, message }) => { + return Ok(make_plaintext_response(status, message)) + } + }; + + // Verify authorization for requested paths. + if let Err(HttpError { status, message }) = verify_paths( + certified_state_reader.get_state(), + &read_state.source, + &read_state.paths, + &targets, + effective_principal_id, + ) { + return Ok(make_plaintext_response(status, message)); + } + + // Create labeled tree. This may be an expensive operation and by + // creating the labeled tree after verifying the paths we know that + // the depth is max 4. + // Always add "time" to the paths even if not explicitly requested. + let mut paths: Vec = read_state.paths; + paths.push(Path::from(Label::from("time"))); + let labeled_tree = match sparse_labeled_tree_from_paths(&paths) { + Ok(tree) => tree, + Err(TooLongPathError) => { + let res = make_plaintext_response( + StatusCode::BAD_REQUEST, + "Failed to parse requested paths: path is too long.".to_string(), + ); + return Ok(res); + } + }; + + let (tree, certification) = + match certified_state_reader.read_certified_state(&labeled_tree) { + Some(r) => r, + None => return Ok(make_service_unavailable_response()), + }; + + let signature = certification.signed.signature.signature.get().0; + let res = HttpReadStateResponse { + certificate: Blob(into_cbor(&Certificate { + tree, + signature: Blob(signature), + delegation: delegation_from_nns, + })), + }; + let (resp, body_size) = cbor_response(&res); + metrics + .response_body_size_bytes + .with_label_values(&[ApiReqType::ReadState.into()]) + .observe(body_size as f64); + Ok(resp) + }) + } +} + +// Verifies that the `user` is authorized to retrieve the `paths` requested. +fn verify_paths( + state: &ReplicatedState, + user: &UserId, + paths: &[Path], + targets: &CanisterIdSet, + effective_principal_id: PrincipalId, +) -> Result<(), HttpError> { + let mut request_status_id: Option = None; + + // Convert the paths to slices to make it easier to match below. + let paths: Vec> = paths + .iter() + .map(|path| path.iter().map(|label| label.as_bytes()).collect()) + .collect(); + + for path in paths { + match path.as_slice() { + [b"time"] => {} + [b"canister", canister_id, b"controllers" | b"module_hash"] => { + let canister_id = parse_principal_id(canister_id)?; + verify_principal_ids(&canister_id, &effective_principal_id)?; + } + [b"canister", canister_id, b"metadata", name] => { + let name = String::from_utf8(Vec::from(*name)).map_err(|err| HttpError { + status: StatusCode::BAD_REQUEST, + message: format!("Could not parse the custom section name: {}.", err), + })?; + + // Get principal id from byte slice. + let principal_id = parse_principal_id(canister_id)?; + // Verify that canister id and effective canister id match. + verify_principal_ids(&principal_id, &effective_principal_id)?; + can_read_canister_metadata( + user, + &CanisterId::new(principal_id).unwrap(), + &name, + state, + )? + } + [b"subnet"] => {} + [b"subnet", _subnet_id] + | [b"subnet", _subnet_id, b"public_key" | b"canister_ranges" | b"node"] => {} + [b"subnet", _subnet_id, b"node", _node_id] + | [b"subnet", _subnet_id, b"node", _node_id, b"public_key"] => {} + [b"request_status", request_id] + | [b"request_status", request_id, b"status" | b"reply" | b"reject_code" | b"reject_message" | b"error_code"] => + { + // Verify that the request was signed by the same user. + if let Ok(message_id) = MessageId::try_from(*request_id) { + if let Some(request_status_id) = request_status_id { + if request_status_id != message_id { + return Err(HttpError { + status: StatusCode::BAD_REQUEST, + message: + "Can only request a single request ID in request_status paths." + .to_string(), + }); + } + } + + let ingress_status = state.get_ingress_status(&message_id); + if let Some(ingress_user_id) = ingress_status.user_id() { + if let Some(receiver) = ingress_status.receiver() { + if ingress_user_id != *user || !targets.contains(&receiver) { + return Err(HttpError { + status: StatusCode::FORBIDDEN, + message: + "Request IDs must be for requests signed by the caller." + .to_string(), + }); + } + } + } + + request_status_id = Some(message_id); + } else { + return Err(HttpError { + status: StatusCode::BAD_REQUEST, + message: format!( + "Request IDs must be {} bytes in length.", + EXPECTED_MESSAGE_ID_LENGTH + ), + }); + } + } + _ => { + // All other paths are unsupported. + return Err(HttpError { + status: StatusCode::NOT_FOUND, + message: "Invalid path requested.".to_string(), + }); + } + } + } + + Ok(()) +} + +fn can_read_canister_metadata( + user: &UserId, + canister_id: &CanisterId, + custom_section_name: &str, + state: &ReplicatedState, +) -> Result<(), HttpError> { + let canister = match state.canister_states.get(canister_id) { + Some(canister) => canister, + None => return Ok(()), + }; + + match &canister.execution_state { + Some(execution_state) => { + let custom_section = match execution_state + .metadata + .get_custom_section(custom_section_name) + { + Some(section) => section, + None => return Ok(()), + }; + + // Only the controller can request this custom section. + if custom_section.visibility() == CustomSectionType::Private + && !canister.system_state.controllers.contains(&user.get()) + { + return Err(HttpError { + status: StatusCode::FORBIDDEN, + message: format!( + "Custom section {:.100} can only be requested by the controllers of the canister.", + custom_section_name + ), + }); + } + + Ok(()) + } + None => Ok(()), + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{ + common::test::{array, assert_cbor_ser_equal, bytes, int}, + HttpError, + }; + use hyper::StatusCode; + use ic_crypto_tree_hash::{Digest, Label, MixedHashTree, Path}; + use ic_registry_subnet_type::SubnetType; + use ic_replicated_state::{CanisterQueues, ReplicatedState, SystemMetadata}; + use ic_test_utilities::{ + mock_time, + state::insert_dummy_canister, + types::ids::{canister_test_id, subnet_test_id, user_test_id}, + }; + use ic_types::batch::ReceivedEpochStats; + use ic_validator::CanisterIdSet; + use std::collections::BTreeMap; + + #[test] + fn encoding_read_state_tree_empty() { + let tree = MixedHashTree::Empty; + assert_cbor_ser_equal(&tree, array(vec![int(0)])); + } + + #[test] + fn encoding_read_state_tree_leaf() { + let tree = MixedHashTree::Leaf(vec![1, 2, 3]); + assert_cbor_ser_equal(&tree, array(vec![int(3), bytes(&[1, 2, 3])])); + } + + #[test] + fn encoding_read_state_tree_pruned() { + let tree = MixedHashTree::Pruned(Digest([1; 32])); + assert_cbor_ser_equal(&tree, array(vec![int(4), bytes(&[1; 32])])); + } + + #[test] + fn encoding_read_state_tree_fork() { + let tree = MixedHashTree::Fork(Box::new(( + MixedHashTree::Leaf(vec![1, 2, 3]), + MixedHashTree::Leaf(vec![4, 5, 6]), + ))); + assert_cbor_ser_equal( + &tree, + array(vec![ + int(1), + array(vec![int(3), bytes(&[1, 2, 3])]), + array(vec![int(3), bytes(&[4, 5, 6])]), + ]), + ); + } + + #[test] + fn encoding_read_state_tree_mixed() { + let tree = MixedHashTree::Fork(Box::new(( + MixedHashTree::Labeled( + Label::from(vec![1, 2, 3]), + Box::new(MixedHashTree::Pruned(Digest([2; 32]))), + ), + MixedHashTree::Leaf(vec![4, 5, 6]), + ))); + assert_cbor_ser_equal( + &tree, + array(vec![ + int(1), + array(vec![ + int(2), + bytes(&[1, 2, 3]), + array(vec![int(4), bytes(&[2; 32])]), + ]), + array(vec![int(3), bytes(&[4, 5, 6])]), + ]), + ); + } + + #[test] + fn user_can_read_canister_metadata() { + let canister_id = canister_test_id(100); + let controller = user_test_id(24); + let non_controller = user_test_id(20); + + let mut state = ReplicatedState::new(subnet_test_id(1), SubnetType::Application); + insert_dummy_canister(&mut state, canister_id, controller.get()); + + let public_name = "dummy"; + // Controller can read the public custom section + assert!(can_read_canister_metadata(&controller, &canister_id, public_name, &state).is_ok()); + + // Non-controller can read public custom section + assert!( + can_read_canister_metadata(&non_controller, &canister_id, public_name, &state).is_ok() + ); + + let private_name = "candid"; + // Controller can read private custom section + assert!( + can_read_canister_metadata(&controller, &canister_id, private_name, &state).is_ok() + ); + } + + #[test] + fn user_cannot_read_canister_metadata() { + let canister_id = canister_test_id(100); + let controller = user_test_id(24); + let non_controller = user_test_id(20); + + let mut state = ReplicatedState::new(subnet_test_id(1), SubnetType::Application); + insert_dummy_canister(&mut state, canister_id, controller.get()); + + // Non-controller cannot read private custom section named `candid`. + assert_eq!( + can_read_canister_metadata(&non_controller, &canister_id, "candid", &state), + Err(HttpError { + status: StatusCode::FORBIDDEN, + message: "Custom section candid can only be requested by the controllers of the canister." + .to_string() + }) + ); + + // Non existent public custom section. + assert_eq!( + can_read_canister_metadata(&non_controller, &canister_id, "unknown-name", &state), + Ok(()) + ); + } + + #[test] + fn test_verify_path() { + let subnet_id = subnet_test_id(1); + let mut metadata = SystemMetadata::new(subnet_id, SubnetType::Application); + metadata.batch_time = mock_time(); + let state = ReplicatedState::new_from_checkpoint( + BTreeMap::new(), + metadata, + CanisterQueues::default(), + ReceivedEpochStats::default(), + ); + assert_eq!( + verify_paths( + &state, + &user_test_id(1), + &[Path::from(Label::from("time"))], + &CanisterIdSet::all(), + canister_test_id(1).get(), + ), + Ok(()) + ); + assert_eq!( + verify_paths( + &state, + &user_test_id(1), + &[ + Path::new(vec![ + Label::from("request_status"), + [0; 32].into(), + Label::from("status") + ]), + Path::new(vec![ + Label::from("request_status"), + [0; 32].into(), + Label::from("reply") + ]) + ], + &CanisterIdSet::all(), + canister_test_id(1).get(), + ), + Ok(()) + ); + assert!(verify_paths( + &state, + &user_test_id(1), + &[ + Path::new(vec![Label::from("request_status"), [0; 32].into()]), + Path::new(vec![Label::from("request_status"), [1; 32].into()]) + ], + &CanisterIdSet::all(), + canister_test_id(1).get(), + ) + .is_err()); + } +} diff --git a/rs/http_endpoints/public/src/read_state/subnet.rs b/rs/http_endpoints/public/src/read_state/subnet.rs new file mode 100644 index 00000000000..5e1c2cdea18 --- /dev/null +++ b/rs/http_endpoints/public/src/read_state/subnet.rs @@ -0,0 +1,317 @@ +use super::{parse_principal_id, verify_principal_ids}; +use crate::{ + body::BodyReceiverLayer, + common::{cbor_response, into_cbor, make_plaintext_response, remove_effective_principal_id}, + metrics::LABEL_UNKNOWN, + state_reader_executor::StateReaderExecutor, + types::ApiReqType, + EndpointService, HttpError, HttpHandlerMetrics, ReplicaHealthStatus, +}; +use crossbeam::atomic::AtomicCell; +use http::Request; +use hyper::{Body, Response, StatusCode}; +use ic_config::http_handler::Config; +use ic_crypto_tree_hash::{sparse_labeled_tree_from_paths, Label, Path, TooLongPathError}; +use ic_logger::{error, ReplicaLogger}; +use ic_types::{ + messages::{ + Blob, Certificate, CertificateDelegation, HttpReadStateContent, HttpReadStateResponse, + HttpRequest, HttpRequestEnvelope, ReadState, SignedRequestBytes, + }, + PrincipalId, +}; +use std::convert::{Infallible, TryFrom}; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, RwLock}; +use std::task::{Context, Poll}; +use tower::{ + limit::concurrency::GlobalConcurrencyLimitLayer, util::BoxCloneService, Service, ServiceBuilder, +}; + +#[derive(Clone)] +pub(crate) struct SubnetReadStateService { + log: ReplicaLogger, + metrics: HttpHandlerMetrics, + health_status: Arc>, + delegation_from_nns: Arc>>, + state_reader_executor: StateReaderExecutor, +} + +impl SubnetReadStateService { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_service( + config: Config, + log: ReplicaLogger, + metrics: HttpHandlerMetrics, + health_status: Arc>, + delegation_from_nns: Arc>>, + state_reader_executor: StateReaderExecutor, + ) -> EndpointService { + let base_service = Self { + log, + metrics, + health_status, + delegation_from_nns, + state_reader_executor, + }; + let base_service = BoxCloneService::new( + ServiceBuilder::new() + .layer(GlobalConcurrencyLimitLayer::new( + config.max_read_state_concurrent_requests, + )) + .service(base_service), + ); + BoxCloneService::new( + ServiceBuilder::new() + .layer(BodyReceiverLayer::new(&config)) + .service(base_service), + ) + } +} + +impl Service>> for SubnetReadStateService { + type Response = Response; + type Error = Infallible; + #[allow(clippy::type_complexity)] + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, request: Request>) -> Self::Future { + self.metrics + .request_body_size_bytes + .with_label_values(&[ApiReqType::ReadState.into(), LABEL_UNKNOWN]) + .observe(request.body().len() as f64); + + if self.health_status.load() != ReplicaHealthStatus::Healthy { + let res = make_plaintext_response( + StatusCode::SERVICE_UNAVAILABLE, + format!( + "Replica is unhealthy: {}. Check the /api/v2/status for more information.", + self.health_status.load(), + ), + ); + return Box::pin(async move { Ok(res) }); + } + let (mut parts, body) = request.into_parts(); + // By removing the principal id we get ownership and avoid having to clone it when creating the future. + let effective_principal_id = match remove_effective_principal_id(&mut parts) { + Ok(canister_id) => canister_id, + Err(res) => { + error!( + self.log, + "Effective canister ID is not attached to read state request. This is a bug." + ); + return Box::pin(async move { Ok(res) }); + } + }; + + let delegation_from_nns = self.delegation_from_nns.read().unwrap().clone(); + + let request = match >::try_from( + &SignedRequestBytes::from(body), + ) { + Ok(request) => request, + Err(e) => { + let res = make_plaintext_response( + StatusCode::BAD_REQUEST, + format!("Could not parse body as read request: {}", e), + ); + return Box::pin(async move { Ok(res) }); + } + }; + + // Convert the message to a strongly-typed struct. + let request = match HttpRequest::::try_from(request) { + Ok(request) => request, + Err(e) => { + let res = make_plaintext_response( + StatusCode::BAD_REQUEST, + format!("Malformed request: {:?}", e), + ); + return Box::pin(async move { Ok(res) }); + } + }; + + let read_state = request.content().clone(); + let state_reader_executor = self.state_reader_executor.clone(); + let metrics = self.metrics.clone(); + Box::pin(async move { + let make_service_unavailable_response = || { + make_plaintext_response( + StatusCode::SERVICE_UNAVAILABLE, + "Certified state is not available yet. Please try again...".to_string(), + ) + }; + let certified_state_reader = + match state_reader_executor.get_certified_state_reader().await { + Ok(Some(reader)) => reader, + Ok(None) => return Ok(make_service_unavailable_response()), + Err(HttpError { status, message }) => { + return Ok(make_plaintext_response(status, message)) + } + }; + + // Verify authorization for requested paths. + if let Err(HttpError { status, message }) = + verify_paths(&read_state.paths, effective_principal_id) + { + return Ok(make_plaintext_response(status, message)); + } + + // Create labeled tree. This may be an expensive operation and by + // creating the labeled tree after verifying the paths we know that + // the depth is max 4. + // Always add "time" to the paths even if not explicitly requested. + let mut paths: Vec = read_state.paths; + paths.push(Path::from(Label::from("time"))); + let labeled_tree = match sparse_labeled_tree_from_paths(&paths) { + Ok(tree) => tree, + Err(TooLongPathError) => { + let res = make_plaintext_response( + StatusCode::BAD_REQUEST, + "Failed to parse requested paths: path is too long.".to_string(), + ); + return Ok(res); + } + }; + + let (tree, certification) = + match certified_state_reader.read_certified_state(&labeled_tree) { + Some(r) => r, + None => return Ok(make_service_unavailable_response()), + }; + + let signature = certification.signed.signature.signature.get().0; + let res = HttpReadStateResponse { + certificate: Blob(into_cbor(&Certificate { + tree, + signature: Blob(signature), + delegation: delegation_from_nns, + })), + }; + let (resp, body_size) = cbor_response(&res); + metrics + .response_body_size_bytes + .with_label_values(&[ApiReqType::ReadState.into()]) + .observe(body_size as f64); + Ok(resp) + }) + } +} + +fn verify_paths(paths: &[Path], effective_principal_id: PrincipalId) -> Result<(), HttpError> { + // Convert the paths to slices to make it easier to match below. + let paths: Vec> = paths + .iter() + .map(|path| path.iter().map(|label| label.as_bytes()).collect()) + .collect(); + + for path in paths { + match path.as_slice() { + [b"time"] | [b"subnet"] => {} + [b"subnet", subnet_id, b"public_key" | b"canister_ranges" | b"metrics"] => { + let principal_id = parse_principal_id(subnet_id)?; + verify_principal_ids(&principal_id, &effective_principal_id)?; + } + [b"subnet", subnet_id, b"node", _node_id] + | [b"subnet", subnet_id, b"node", _node_id, b"public_key"] => { + let principal_id = parse_principal_id(subnet_id)?; + verify_principal_ids(&principal_id, &effective_principal_id)?; + } + _ => { + // All other paths are unsupported. + return Err(HttpError { + status: StatusCode::NOT_FOUND, + message: "Invalid path requested.".to_string(), + }); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use ic_crypto_tree_hash::{Label, Path}; + use ic_test_utilities::types::ids::{canister_test_id, subnet_test_id}; + use serde_bytes::ByteBuf; + + #[test] + fn test_verify_path() { + assert_eq!( + verify_paths(&[Path::from(Label::from("time"))], subnet_test_id(1).get(),), + Ok(()) + ); + assert_eq!( + verify_paths( + &[Path::from(Label::from("subnet"))], + subnet_test_id(1).get(), + ), + Ok(()) + ); + + assert_eq!( + verify_paths( + &[ + Path::new(vec![ + Label::from("subnet"), + ByteBuf::from(subnet_test_id(1).get().to_vec()).into(), + Label::from("public_key") + ]), + Path::new(vec![ + Label::from("subnet"), + ByteBuf::from(subnet_test_id(1).get().to_vec()).into(), + Label::from("canister_ranges") + ]), + Path::new(vec![ + Label::from("subnet"), + ByteBuf::from(subnet_test_id(1).get().to_vec()).into(), + Label::from("metrics") + ]), + ], + subnet_test_id(1).get(), + ), + Ok(()) + ); + + assert!(verify_paths( + &[ + Path::new(vec![ + Label::from("request_status"), + [0; 32].into(), + Label::from("status") + ]), + Path::new(vec![ + Label::from("request_status"), + [0; 32].into(), + Label::from("reply") + ]) + ], + subnet_test_id(1).get(), + ) + .is_err()); + + assert!(verify_paths( + &[ + Path::new(vec![ + Label::from("canister"), + ByteBuf::from(canister_test_id(1).get().to_vec()).into(), + Label::from("controllers") + ]), + Path::new(vec![ + Label::from("request_status"), + ByteBuf::from(canister_test_id(1).get().to_vec()).into(), + Label::from("module_hash") + ]) + ], + subnet_test_id(1).get(), + ) + .is_err()); + } +} diff --git a/rs/http_endpoints/public/tests/test.rs b/rs/http_endpoints/public/tests/test.rs index cabfb44c07d..c6f92ccf32c 100644 --- a/rs/http_endpoints/public/tests/test.rs +++ b/rs/http_endpoints/public/tests/test.rs @@ -191,7 +191,7 @@ fn test_unauthorized_controller() { status: 400, content_type: Some("text/plain".to_string()), content: format!( - "Effective canister id in URL {} does not match requested canister id: {}.", + "Effective principal id in URL {} does not match requested principal id: {}.", canister1, canister2 ) .as_bytes()