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 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 #[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#[derive(Debug, Default)]
61struct TLSConfigResolved {
62 identity: Option<tonic::transport::Identity>,
63 ca_certificate: Option<tonic::transport::Certificate>,
64}
65
66impl TonicConnector {
67 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 if !url.path().is_empty() {
101 return Err(Error::PathMayNotBeSet());
102 }
103
104 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 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 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 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
217async 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
257fn 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
285pub async fn channel_from_url(url: &url::Url) -> Result<Channel, Error> {
291 TonicConnector::from_url(url)?.connect().await
292}
293
294#[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 #[case::valid_unix_socket("grpc+unix:/path/to/somewhere", true)]
337 #[case::valid_unix_socket_wait_connect_0("grpc+unix:/path/to/somewhere?wait-connect=0", true)]
339 #[case::valid_unix_socket_wait_connect_1("grpc+unix:/path/to/somewhere?wait-connect=1", false)]
341 #[case::invalid_unix_socket_with_authority("grpc+unix:///path/to/somewhere", false)]
343 #[case::invalid_unix_socket_and_host("grpc+unix://host.example/path/to/somewhere", false)]
345 #[case::valid_ipv6_localhost_port_12345("grpc+http://[::1]:12345", true)]
347 #[case::valid_http_host_without_port("grpc+http://localhost", true)]
349 #[case::valid_https_host_without_port("grpc+https://localhost", true)]
351 #[case::invalid_host_and_path("grpc+http://localhost/some-path", false)]
353 #[case::valid_host_wait_connect_0("grpc+http://localhost?wait-connect=0", true)]
355 #[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 #[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}