Skip to main content

linera_rpc/grpc/
mod.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4mod client;
5mod conversions;
6mod node_provider;
7/// A pool of reusable gRPC transport channels.
8pub mod pool;
9mod relay;
10#[cfg(with_server)]
11mod server;
12/// Transport-level configuration and channel construction for gRPC.
13pub mod transport;
14
15pub use client::*;
16pub use conversions::*;
17pub use node_provider::*;
18pub use relay::{RelayClient, RelayNodeProvider};
19#[cfg(with_server)]
20pub use server::*;
21
22/// The gRPC service and message types generated from `proto/rpc.proto`.
23pub mod api {
24    // Generated gRPC bindings; the generated items cannot carry doc comments.
25    #![allow(missing_docs)]
26    tonic::include_proto!("rpc.v1");
27}
28
29#[derive(thiserror::Error, Debug)]
30#[allow(missing_docs)]
31pub enum GrpcError {
32    #[error("failed to connect to address: {0}")]
33    ConnectionFailed(#[from] transport::Error),
34
35    #[error("failed to execute task to completion: {0}")]
36    Join(#[from] futures::channel::oneshot::Canceled),
37
38    #[error("failed to parse socket address: {0}")]
39    SocketAddr(#[from] std::net::AddrParseError),
40
41    #[cfg(with_server)]
42    #[error(transparent)]
43    Reflection(#[from] tonic_reflection::server::Error),
44}
45
46const MEBIBYTE: usize = 1024 * 1024;
47/// The maximum gRPC message size, in bytes.
48pub const GRPC_MAX_MESSAGE_SIZE: usize = 16 * MEBIBYTE;
49
50/// Limit of gRPC message size up to which we will try to populate with data when estimating.
51/// We leave 30% of buffer for the rest of the message and potential underestimation.
52pub const GRPC_CHUNKED_MESSAGE_FILL_LIMIT: usize = GRPC_MAX_MESSAGE_SIZE * 7 / 10;
53
54/// Prometheus label for the gRPC method name.
55pub const METHOD_NAME_LABEL: &str = "method_name";
56
57/// Prometheus label for distinguishing organic vs synthetic (benchmark) traffic.
58pub const TRAFFIC_TYPE_LABEL: &str = "traffic_type";
59
60/// Prometheus label for the error variant name, e.g. `"WorkerError::UnexpectedBlockHeight"`.
61pub const ERROR_TYPE_LABEL: &str = "error_type";
62
63/// Maximum length of a single proto identifier accepted as a service or method name.
64/// Real proto identifiers are far shorter than this; the cap guards against attacker
65/// input being recorded verbatim as a Prometheus label value.
66const MAX_PROTO_IDENT_LEN: usize = 128;
67
68/// Returns `true` if `s` is a valid protobuf identifier (`[A-Za-z][A-Za-z0-9_]*`)
69/// within the length cap.
70fn is_proto_identifier(s: &str) -> bool {
71    if s.is_empty() || s.len() > MAX_PROTO_IDENT_LEN {
72        return false;
73    }
74    let mut bytes = s.bytes();
75    let first = bytes.next().expect("non-empty checked above");
76    first.is_ascii_alphabetic() && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
77}
78
79/// Returns `true` if `s` is a fully-qualified proto Service-Name: two or more
80/// proto identifiers joined by dots, e.g. `package.Service` or `outer.inner.Service`.
81fn is_proto_service_name(s: &str) -> bool {
82    let mut parts = s.split('.');
83    let Some(first) = parts.next() else {
84        return false;
85    };
86    let Some(second) = parts.next() else {
87        return false;
88    };
89    is_proto_identifier(first) && is_proto_identifier(second) && parts.all(is_proto_identifier)
90}
91
92/// Extracts the gRPC method name from a request URI path.
93///
94/// gRPC paths follow the HTTP/2 form `"/" Service-Name "/" {method name}`, where
95/// `Service-Name` is `{proto package} "." {service name}` and the method name is a
96/// single proto identifier. Anything that does not match this exact shape — health
97/// probes, browser requests, bot scans probing for `.env` files, etc. — is mapped
98/// to `"non_grpc"` so the value is safe to use as a Prometheus label without
99/// blowing up cardinality or label-value length.
100pub fn extract_grpc_method_name(path: &str) -> &str {
101    let mut parts = path.splitn(3, '/');
102    let (Some(""), Some(service), Some(method)) = (parts.next(), parts.next(), parts.next()) else {
103        return "non_grpc";
104    };
105    if is_proto_service_name(service) && is_proto_identifier(method) {
106        method
107    } else {
108        "non_grpc"
109    }
110}
111
112#[cfg(with_metrics)]
113pub(crate) fn init_metrics() {
114    client::metrics::init_metrics();
115    #[cfg(with_server)]
116    server::metrics::init_metrics();
117}
118
119#[cfg(test)]
120mod method_name_tests {
121    use super::*;
122
123    #[test]
124    fn grpc_unary_method() {
125        assert_eq!(
126            extract_grpc_method_name("/rpc.v1.ValidatorNode/HandleBlockProposal"),
127            "HandleBlockProposal"
128        );
129    }
130
131    #[test]
132    fn grpc_streaming_method() {
133        assert_eq!(
134            extract_grpc_method_name("/rpc.v1.ValidatorNode/SubscribeToNotifications"),
135            "SubscribeToNotifications"
136        );
137    }
138
139    #[test]
140    fn health_check_path() {
141        assert_eq!(
142            extract_grpc_method_name("/grpc.health.v1.Health/Check"),
143            "Check"
144        );
145    }
146
147    #[test]
148    fn non_grpc_root_path() {
149        assert_eq!(extract_grpc_method_name("/"), "non_grpc");
150    }
151
152    #[test]
153    fn non_grpc_plain_path() {
154        assert_eq!(extract_grpc_method_name("/healthz"), "non_grpc");
155    }
156
157    #[test]
158    fn non_grpc_no_dot_in_service() {
159        assert_eq!(extract_grpc_method_name("/NoDotService/Method"), "non_grpc");
160    }
161
162    #[test]
163    fn empty_path() {
164        assert_eq!(extract_grpc_method_name(""), "non_grpc");
165    }
166
167    #[test]
168    fn dot_env_bot_scan_does_not_leak_into_label() {
169        assert_eq!(
170            extract_grpc_method_name(
171                "/.env.local/.env.production/.env.staging/.env.development/.env.test"
172            ),
173            "non_grpc"
174        );
175    }
176
177    #[test]
178    fn service_starting_with_dot_is_rejected() {
179        assert_eq!(extract_grpc_method_name("/.foo.bar/Method"), "non_grpc");
180    }
181
182    #[test]
183    fn method_with_extra_path_segments_is_rejected() {
184        assert_eq!(
185            extract_grpc_method_name("/foo.bar/Method/extra"),
186            "non_grpc"
187        );
188    }
189
190    #[test]
191    fn method_with_invalid_characters_is_rejected() {
192        assert_eq!(extract_grpc_method_name("/foo.bar/Method-x"), "non_grpc");
193        assert_eq!(extract_grpc_method_name("/foo.bar/Method?x"), "non_grpc");
194        assert_eq!(extract_grpc_method_name("/foo.bar/.Method"), "non_grpc");
195    }
196
197    #[test]
198    fn identifier_starting_with_underscore_is_rejected() {
199        assert_eq!(extract_grpc_method_name("/foo.bar/_Method"), "non_grpc");
200        assert_eq!(extract_grpc_method_name("/_foo.bar/Method"), "non_grpc");
201        assert_eq!(
202            extract_grpc_method_name("/foo.bar/________________"),
203            "non_grpc"
204        );
205    }
206
207    #[test]
208    fn empty_method_segment_is_rejected() {
209        assert_eq!(extract_grpc_method_name("/foo.bar/"), "non_grpc");
210    }
211
212    #[test]
213    fn empty_service_segment_is_rejected() {
214        assert_eq!(extract_grpc_method_name("//Method"), "non_grpc");
215    }
216
217    #[test]
218    fn service_with_consecutive_dots_is_rejected() {
219        assert_eq!(extract_grpc_method_name("/foo..bar/Method"), "non_grpc");
220    }
221
222    #[test]
223    fn overlong_method_is_rejected() {
224        let long_method = "M".repeat(MAX_PROTO_IDENT_LEN + 1);
225        let path = format!("/foo.bar/{long_method}");
226        assert_eq!(extract_grpc_method_name(&path), "non_grpc");
227    }
228
229    #[test]
230    fn path_without_leading_slash_is_rejected() {
231        assert_eq!(extract_grpc_method_name("foo.bar/Method"), "non_grpc");
232    }
233}