architect_sdk/
grpc.rs

1//! Useful/safe/Architect-tuned abstractions for gRPC clients
2
3#[cfg(feature = "grpc-tls")]
4use anyhow::Context;
5use anyhow::Result;
6use architect_api::HumanDuration;
7use clap::Args;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10#[cfg(feature = "grpc-tls")]
11use tonic::transport::{Certificate, ClientTlsConfig};
12use tonic::transport::{Channel, Endpoint};
13use url::Url;
14
15#[cfg(feature = "grpc-tls")]
16const ARCHITECT_CA: &[u8] = include_bytes!("ca.crt");
17
18#[derive(Debug, Clone, Serialize, Deserialize, Args)]
19pub struct GrpcClientConfig {
20    /// Connect timeout, e.g. 3s for three seconds
21    #[serde(default)]
22    #[arg(long)]
23    pub connect_timeout: Option<HumanDuration>,
24    #[serde(default)]
25    #[arg(skip)]
26    pub connect_lazy: bool,
27    /// Specify that TLS client auth should be used.  If a TLS identity
28    /// isn't explicitly provided, look for the Architect license file
29    /// in the default directory.
30    #[serde(default)]
31    #[arg(long)]
32    pub tls_client: bool,
33    /// Specify a certificate to present for TLS client certificate auth;
34    /// the corresponding private key file will be looked for at the same
35    /// path but with .key extension, unless explicitly overriden.
36    #[serde(default)]
37    #[arg(long)]
38    pub tls_identity: Option<PathBuf>,
39    /// Explicitly specify the private key for TLS client certificate
40    /// auth, if not at the canonical location.
41    #[serde(default)]
42    #[arg(long)]
43    pub tls_identity_key: Option<PathBuf>,
44    // CR alee: for localhost, prefer `dangerous_accept_any_tls_for_localhost`;
45    // implementing requires a change in tonic
46    /// Manually specify an additional root certificate to verify server TLS
47    #[serde(default)]
48    #[arg(long = "ca")]
49    pub dangerous_additional_ca_certificate: Option<PathBuf>,
50}
51
52impl Default for GrpcClientConfig {
53    fn default() -> Self {
54        Self {
55            connect_timeout: Self::default_connect_timeout(),
56            connect_lazy: false,
57            tls_client: false,
58            tls_identity: None,
59            tls_identity_key: None,
60            dangerous_additional_ca_certificate: None,
61        }
62    }
63}
64
65impl GrpcClientConfig {
66    fn default_connect_timeout() -> Option<HumanDuration> {
67        Some(chrono::Duration::seconds(3).into())
68    }
69
70    /// Open a gRPC channel to the given endpoint URL
71    pub async fn connect(&self, url: &Url) -> Result<Channel> {
72        let endpoint = Endpoint::try_from(url.to_string())?;
73        self.connect_to(endpoint).await
74    }
75
76    /// Open a gRPC channel to the given endpoint
77    pub async fn connect_to(&self, mut endpoint: Endpoint) -> Result<Channel> {
78        if let Some(dur) = self.connect_timeout {
79            endpoint = endpoint.connect_timeout((*dur).to_std()?);
80        }
81        #[cfg(feature = "grpc-tls")]
82        if endpoint.uri().scheme_str() == Some("https") {
83            let ca = Certificate::from_pem(ARCHITECT_CA);
84            let mut tls_config =
85                ClientTlsConfig::new().with_enabled_roots().ca_certificate(ca);
86            if let Some(add_ca_path) = &self.dangerous_additional_ca_certificate {
87                let add_ca_pem = tokio::fs::read(add_ca_path)
88                    .await
89                    .with_context(|| format!("reading {}", add_ca_path.display()))?;
90                let add_ca = Certificate::from_pem(&add_ca_pem);
91                tls_config = tls_config.ca_certificate(add_ca);
92                // useful notice
93                if endpoint.uri().host().is_some_and(is_ip_address) {
94                    log::error!("url is not a domain name but scheme is https; this will always fail tls domain verification");
95                    log::error!("if connecting to 127.0.0.1--use \"localhost\" instead")
96                }
97            }
98            if self.tls_client || self.tls_identity.is_some() {
99                if let Some(cert_path) = &self.tls_identity {
100                    let identity = grpc_tls_identity_from_pem_files(
101                        cert_path,
102                        self.tls_identity_key.as_ref(),
103                    )
104                    .await?;
105                    tls_config = tls_config.identity(identity);
106                } else {
107                    // try the Architect/netidx default location
108                    let mut cert_path = dirs::config_dir().ok_or_else(|| {
109                        anyhow::anyhow!("could not determine default config directory")
110                    })?;
111                    cert_path.push("netidx");
112                    cert_path.push("license.crt");
113                    let identity = grpc_tls_identity_from_pem_files(
114                        cert_path,
115                        self.tls_identity_key.as_ref(),
116                    )
117                    .await?;
118                    tls_config = tls_config.identity(identity);
119                }
120            }
121            endpoint = endpoint.tls_config(tls_config)?;
122        }
123        #[cfg(not(feature = "grpc-tls"))]
124        if endpoint.uri().scheme_str() == Some("https") {
125            anyhow::bail!("endpoint schema is https but grpc-tls is not enabled");
126        }
127        let channel = if self.connect_lazy {
128            endpoint.connect_lazy()
129        } else {
130            endpoint.connect().await?
131        };
132        Ok(channel)
133    }
134}
135
136#[cfg(feature = "grpc-tls")]
137fn is_ip_address(host: &str) -> bool {
138    use std::str::FromStr;
139    std::net::IpAddr::from_str(host).is_ok()
140}
141
142/// Canonical reading of a TLS identity from file for tonic/gRPC
143///
144/// If `pkey_path` isn't provided, look for it where `cert_path`
145/// is but with the `.key` extension.
146#[cfg(feature = "grpc-tls")]
147pub async fn grpc_tls_identity_from_pem_files(
148    cert_path: impl AsRef<std::path::Path>,
149    pkey_path: Option<impl AsRef<std::path::Path>>,
150) -> Result<tonic::transport::Identity> {
151    let cert_path = cert_path.as_ref().to_owned();
152    let pkey_path = match pkey_path {
153        Some(path) => path.as_ref().to_owned(),
154        None => cert_path.with_extension("key"),
155    };
156    let cert_pem = tokio::fs::read(&cert_path)
157        .await
158        .with_context(|| format!("reading {}", cert_path.display()))?;
159    let pkey_pem = tokio::fs::read(&pkey_path)
160        .await
161        .with_context(|| format!("reading {}", pkey_path.display()))?;
162    Ok(tonic::transport::Identity::from_pem(&cert_pem, &pkey_pem))
163}