Skip to main content

snix_castore/
tonic.rs

1use std::path::PathBuf;
2
3use hyper_util::rt::TokioIo;
4use serde::{
5    Deserialize, Deserializer,
6    de::{self, Unexpected},
7};
8use tokio::net::UnixStream;
9use tonic::transport::{Channel, Endpoint};
10
11#[derive(Debug)]
12pub struct TonicConnector {
13    transport: Transport,
14    url_params: URLParams,
15}
16
17#[derive(Debug)]
18enum Transport {
19    HTTP2 {
20        url: String,
21        /// The option signals if TLS itself is configured or not.
22        tls_config_params: Option<TLSConfigParams>,
23    },
24    UnixDomainSocket {
25        path: PathBuf,
26    },
27}
28
29#[derive(Debug, Default, Deserialize)]
30#[serde(rename_all = "kebab-case")]
31struct URLParams {
32    /// Whether to connect eagerly
33    #[serde(deserialize_with = "bool_from_int", default)]
34    wait_connect: bool,
35}
36
37fn bool_from_int<'de, D>(deserializer: D) -> Result<bool, D::Error>
38where
39    D: Deserializer<'de>,
40{
41    match u8::deserialize(deserializer)? {
42        0 => Ok(false),
43        1 => Ok(true),
44        other => Err(de::Error::invalid_value(
45            Unexpected::Unsigned(other as u64),
46            &"zero or one",
47        )),
48    }
49}
50
51#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "kebab-case")]
53struct TLSConfigParams {
54    tls_client_cert_path: Option<PathBuf>,
55    tls_client_key_path: Option<PathBuf>,
56    tls_ca_cert_path: Option<PathBuf>,
57}
58
59/// TLS Config with already read files.
60#[derive(Debug, Default)]
61struct TLSConfigResolved {
62    identity: Option<tonic::transport::Identity>,
63    ca_certificate: Option<tonic::transport::Certificate>,
64}
65
66impl TonicConnector {
67    /// All URLs support adding `wait-connect=1` as a URL parameter, in which case
68    /// the connection is established lazily.
69    pub fn from_url(url: &url::Url) -> Result<TonicConnector, self::Error> {
70        let url_params = url
71            .query()
72            .map(serde_qs::from_str::<URLParams>)
73            .transpose()?
74            .unwrap_or_default();
75
76        let tls_config_params = url
77            .query()
78            .map(serde_qs::from_str::<TLSConfigParams>)
79            .transpose()?
80            .unwrap_or_default();
81        if url.scheme() != "grpc+https" && tls_config_params != TLSConfigParams::default() {
82            return Err(Error::TLSConfigOnPlain);
83        }
84
85        match url.scheme() {
86            "grpc+unix" => {
87                if url.has_authority() {
88                    return Err(Error::AuthorityDisallowed());
89                }
90
91                Ok(Self {
92                    transport: Transport::UnixDomainSocket {
93                        path: url.path().into(),
94                    },
95                    url_params,
96                })
97            }
98            "grpc+http" | "grpc+https" => {
99                // ensure path is empty, not supported with gRPC.
100                if !url.path().is_empty() {
101                    return Err(Error::PathMayNotBeSet());
102                }
103
104                // Stringify the URL and remove the grpc+ prefix.
105                // We can't use `url.set_scheme(rest)`, as it disallows
106                // setting something http(s) that previously wasn't.
107                let unprefixed_url = url
108                    .as_str()
109                    .strip_prefix("grpc+")
110                    .expect("grpc+ is prefix")
111                    .to_string();
112
113                Ok(Self {
114                    transport: Transport::HTTP2 {
115                        url: unprefixed_url,
116                        tls_config_params: (url.scheme() == "grpc+https")
117                            .then_some(tls_config_params),
118                    },
119                    url_params,
120                })
121            }
122            scheme => Err(Error::UnsupportedScheme(scheme.to_owned())),
123        }
124    }
125
126    // Tries to connect lazily
127    // Will panic if `wait-connect=1` was set in the URL, as connecting
128    // non-lazily needs to be async, use [Self::connect] for that.
129    pub async fn connect(self) -> Result<Channel, Error> {
130        match self.transport {
131            Transport::HTTP2 {
132                url,
133                tls_config_params: None,
134            } => {
135                let endpoint = setup_endpoint_http(url, None)?;
136
137                Ok(if self.url_params.wait_connect {
138                    endpoint.connect().await?
139                } else {
140                    endpoint.connect_lazy()
141                })
142            }
143            Transport::HTTP2 {
144                url,
145                tls_config_params: Some(tls_config_params),
146            } => {
147                let endpoint =
148                    setup_endpoint_http(url, Some(resolve_tls_config(tls_config_params).await?))?;
149
150                Ok(if self.url_params.wait_connect {
151                    endpoint.connect().await?
152                } else {
153                    endpoint.connect_lazy()
154                })
155            }
156            Transport::UnixDomainSocket { path } => {
157                let connector = tower::service_fn(move |_| {
158                    let unix = UnixStream::connect(path.clone());
159                    async move { Ok::<_, std::io::Error>(TokioIo::new(unix.await?)) }
160                });
161
162                let endpoint = Endpoint::from_static("http://[::]:50051");
163                Ok(if self.url_params.wait_connect {
164                    endpoint.connect_with_connector(connector).await?
165                } else {
166                    endpoint.connect_with_connector_lazy(connector)
167                })
168            }
169        }
170    }
171
172    // Tries to connect lazily
173    // Will panic if `wait-connect=1` was set in the URL, as connecting
174    // non-lazily needs to be async, use [Self::connect] for that.
175    pub fn connect_expect_lazy(self) -> Channel {
176        assert!(
177            !self.url_params.wait_connect,
178            "wait-connect URL called with connect_lazy"
179        );
180
181        match self.transport {
182            Transport::HTTP2 {
183                url,
184                tls_config_params,
185            } => {
186                if tls_config_params == Some(TLSConfigParams::default())
187                    || tls_config_params.is_none()
188                {
189                    let endpoint = setup_endpoint_http(
190                        url,
191                        tls_config_params.map(|_| TLSConfigResolved::default()),
192                    )
193                    .expect("to not fail");
194
195                    endpoint.connect_lazy()
196                } else {
197                    // NOTE: we cannot reach this code right now, as the only user
198                    // of `connect_expect_lazy` (`NixHTTPPathInfoService`) does not yet support any
199                    // TLS config.
200                    todo!("implement me");
201                }
202            }
203            Transport::UnixDomainSocket { path } => {
204                let connector = tower::service_fn(move |_| {
205                    let unix = UnixStream::connect(path.clone());
206                    async move { Ok::<_, std::io::Error>(TokioIo::new(unix.await?)) }
207                });
208
209                let endpoint = Endpoint::from_static("http://[::]:50051");
210
211                endpoint.connect_with_connector_lazy(connector)
212            }
213        }
214    }
215}
216
217/// Takes a [TLSConfigParams], and returns a [TLSConfigResolved].
218/// Going from one to the other potentially requires doing IO, so this function
219/// is async.
220async fn resolve_tls_config(
221    tls_config_params: TLSConfigParams,
222) -> Result<TLSConfigResolved, Error> {
223    let identity = match (
224        tls_config_params.tls_client_cert_path,
225        tls_config_params.tls_client_key_path,
226    ) {
227        (Some(p_cert), Some(p_key)) => {
228            let cert_pem = tokio::fs::read_to_string(&p_cert)
229                .await
230                .map_err(|err| Error::ReadingFile("tls_client_cert_path", p_cert, err))?;
231            let key_pem = tokio::fs::read_to_string(&p_key)
232                .await
233                .map_err(|err| Error::ReadingFile("tls_client_key_path", p_key, err))?;
234
235            Some(tonic::transport::Identity::from_pem(cert_pem, key_pem))
236        }
237        (None, None) => None,
238        _ => return Err(Error::TLSIdentityPartial),
239    };
240
241    let ca_certificate = if let Some(p) = tls_config_params.tls_ca_cert_path {
242        let ca_cert_pem = tokio::fs::read_to_string(&p)
243            .await
244            .map_err(|err| Error::ReadingFile("tls_ca_cert_path", p, err))?;
245
246        Some(tonic::transport::Certificate::from_pem(ca_cert_pem))
247    } else {
248        None
249    };
250
251    Ok(TLSConfigResolved {
252        identity,
253        ca_certificate,
254    })
255}
256
257/// Helper function configuring [Endpoint] from a url and `Option<TLSConfigResolved>`.
258/// Factored out to be used from both [TonicConnector::connect] and
259/// [TonicConnector::connect_expect_lazy].
260fn setup_endpoint_http(
261    url: String,
262    tls_config_params: Option<TLSConfigResolved>,
263) -> Result<Endpoint, Error> {
264    let mut endpoint = Endpoint::from_shared(url)?;
265
266    if let Some(tls_config_params) = tls_config_params {
267        let mut client_tls_config = tonic::transport::ClientTlsConfig::new();
268
269        if let Some(ca_cert) = tls_config_params.ca_certificate {
270            client_tls_config = client_tls_config.ca_certificates([ca_cert])
271        } else {
272            client_tls_config = client_tls_config.with_enabled_roots()
273        };
274
275        if let Some(identity) = tls_config_params.identity {
276            client_tls_config = client_tls_config.identity(identity)
277        }
278
279        endpoint = endpoint.tls_config(client_tls_config)?;
280    }
281
282    Ok(endpoint)
283}
284
285/// Turn a [url::Url] to a [Channel] if it can be parsed successfully.
286/// It supports the following schemes (and URLs):
287///  - `grpc+http://[::1]:8000`, connecting over unencrypted HTTP/2 (h2c)
288///  - `grpc+https://[::1]:8000`, connecting over encrypted HTTP/2
289///  - `grpc+unix:/path/to/socket`, connecting to a unix domain socket
290pub async fn channel_from_url(url: &url::Url) -> Result<Channel, Error> {
291    TonicConnector::from_url(url)?.connect().await
292}
293
294/// Errors occuring when parsing a gRPC backend URL, or trying to connect to it.
295#[derive(Debug, thiserror::Error)]
296pub enum Error {
297    #[error("Unsupported gRPC scheme: {0}")]
298    UnsupportedScheme(String),
299
300    #[error("unix domain sockets URLs should not have authority")]
301    AuthorityDisallowed(),
302
303    #[error("path may not be set")]
304    PathMayNotBeSet(),
305
306    #[error("parsing query string")]
307    ParsingQS(#[from] serde_qs::Error),
308
309    #[error("reading {0} at {1}: {2}")]
310    ReadingFile(&'static str, PathBuf, std::io::Error),
311
312    #[error("transport error: {0}")]
313    TransportError(tonic::transport::Error),
314
315    #[error("unexpected TLS config on non-TLS URL")]
316    TLSConfigOnPlain,
317
318    #[error("Only one of TLS Identity key and cert specified")]
319    TLSIdentityPartial,
320}
321
322impl From<tonic::transport::Error> for Error {
323    fn from(value: tonic::transport::Error) -> Self {
324        Self::TransportError(value)
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::{TonicConnector, channel_from_url};
331    use rstest::rstest;
332    use url::Url;
333
334    #[rstest]
335    /// Correct scheme to connect to a unix socket.
336    #[case::valid_unix_socket("grpc+unix:/path/to/somewhere", true)]
337    /// Connecting with wait-connect set to 0 succeeds, as that's the default.
338    #[case::valid_unix_socket_wait_connect_0("grpc+unix:/path/to/somewhere?wait-connect=0", true)]
339    /// Connecting with wait-connect set to 1 fails, as the path doesn't exist.
340    #[case::valid_unix_socket_wait_connect_1("grpc+unix:/path/to/somewhere?wait-connect=1", false)]
341    /// Correct scheme for unix socket, but setting authority, which is invalid.
342    #[case::invalid_unix_socket_with_authority("grpc+unix:///path/to/somewhere", false)]
343    /// Correct scheme for unix socket, but setting a host too, which is invalid.
344    #[case::invalid_unix_socket_and_host("grpc+unix://host.example/path/to/somewhere", false)]
345    /// Correct scheme to connect to localhost, with port 12345
346    #[case::valid_ipv6_localhost_port_12345("grpc+http://[::1]:12345", true)]
347    /// Correct scheme to connect to localhost over http, without specifying a port.
348    #[case::valid_http_host_without_port("grpc+http://localhost", true)]
349    /// Correct scheme to connect to localhost over http, without specifying a port.
350    #[case::valid_https_host_without_port("grpc+https://localhost", true)]
351    /// Correct scheme to connect to localhost over http, but with additional path, which is invalid.
352    #[case::invalid_host_and_path("grpc+http://localhost/some-path", false)]
353    /// Connecting with wait-connect set to 0 succeeds, as that's the default.
354    #[case::valid_host_wait_connect_0("grpc+http://localhost?wait-connect=0", true)]
355    /// Connecting with wait-connect set to 1 fails, as the host doesn't exist.
356    #[case::valid_host_wait_connect_1_fails("grpc+http://nonexist.invalid?wait-connect=1", false)]
357    #[tokio::test]
358    async fn test_channel_from_url_tokio(#[case] uri_str: &str, #[case] is_ok: bool) {
359        use pretty_assertions::assert_matches;
360        let url = Url::parse(uri_str).expect("must parse");
361
362        if is_ok {
363            assert_matches!(channel_from_url(&url).await, Ok(_))
364        } else {
365            assert_matches!(channel_from_url(&url).await, Err(_))
366        }
367    }
368
369    #[rstest]
370    /// A bunch of tests testing (m)TLS-related URLs.
371    /// We test from_url directly, as the connect() part, even in the lazy default case
372    /// will read keys from disk not only when doing the first request, but during the function call.
373    #[case::valid_mtls_custom_cacert("grpc+https://localhost?tls-ca-cert-path=/dev/null", true)]
374    #[case::valid_mtls(
375        "grpc+https://localhost?tls-client-cert-path=/dev/null&tls-client-key-path=/dev/null",
376        true
377    )]
378    #[case::valid_mtls_custom_cacert(
379        "grpc+https://localhost?tls-client-cert-path=/dev/null&tls-client-key-path=/dev/null&tls-ca-cert-path=/dev/null",
380        true
381    )]
382    #[tokio::test]
383    async fn test_from_url(#[case] uri_str: &str, #[case] is_ok: bool) {
384        use pretty_assertions::assert_matches;
385        let url = Url::parse(uri_str).expect("must parse");
386
387        let result = TonicConnector::from_url(&url);
388
389        if is_ok {
390            assert_matches!(result, Ok(_))
391        } else {
392            assert_matches!(result, Err(_))
393        }
394    }
395}