Skip to main content

manycastr/
tls.rs

1use clap::ArgMatches;
2use log::{info, warn};
3use std::error::Error;
4use std::fs;
5use std::net::IpAddr;
6use std::path::{Path, PathBuf};
7use tonic::transport::{Certificate, ClientTlsConfig, Identity};
8use x509_parser::prelude::{GeneralName, Pem, X509Certificate};
9
10/// PEM label of an X.509 certificate (other blocks, e.g. private keys, are ignored).
11const CERTIFICATE_LABEL: &str = "CERTIFICATE";
12
13/// Track what the SAN is authenticated against (based on user parameters).
14enum NameSource {
15    /// Given explicitly with `--tls_domain`
16    Flag,
17    /// The host part of the orchestrator address (`-a`)
18    Address,
19    /// The Subject Alternative Name of a pinned self-signed certificate
20    Pinned,
21}
22
23impl NameSource {
24    fn as_str(&self) -> &'static str {
25        match self {
26            NameSource::Flag => "--tls_domain",
27            NameSource::Address => "-a",
28            NameSource::Pinned => "certificate SAN",
29        }
30    }
31}
32
33/// The TLS settings of a worker or CLI, as given on the command line.
34pub struct TlsOptions<'a> {
35    /// Path to the trust anchor to verify the orchestrator against (`--tls`)
36    cert_path: Option<&'a str>,
37    /// Verify against the host's system trust store (`--tls_system`) (if true)
38    system_roots: bool,
39    /// The name to verify the orchestrator as, overriding the address (`--tls_domain`)
40    domain: Option<&'a str>,
41}
42
43impl<'a> TlsOptions<'a> {
44    /// Read the TLS settings from the parsed command-line arguments of a worker or CLI.
45    pub fn from_args(args: &'a ArgMatches) -> Self {
46        Self {
47            cert_path: args.get_one::<String>("tls").map(String::as_str),
48            system_roots: args.get_flag("tls_system"),
49            domain: args.get_one::<String>("tls_domain").map(String::as_str),
50        }
51    }
52
53    /// Whether the connection to the orchestrator is secured with TLS.
54    pub fn is_enabled(&self) -> bool {
55        self.cert_path.is_some() || self.system_roots
56    }
57
58    /// Build the client-side TLS configuration used by workers and the CLI.
59    ///
60    /// # Arguments
61    /// * `address` - the orchestrator address (`-a`), whose host names the orchestrator
62    ///
63    /// # Returns
64    /// A tonic [`ClientTlsConfig`] holding the trust anchors and the name to authenticate.
65    ///
66    /// # Errors
67    /// If the certificate file cannot be read or holds no PEM certificate.
68    pub fn client_config(&self, address: &str) -> Result<ClientTlsConfig, Box<dyn Error>> {
69        let Some(cert_path) = self.cert_path else {
70            // Use the trust store
71            let (server_name, source) = server_name(&[], address, self.domain);
72            info!(
73                "[TLS] Trusting the system trust store, authenticating orchestrator as '{server_name}' (from {})",
74                source.as_str()
75            );
76
77            return Ok(ClientTlsConfig::new()
78                .with_native_roots()
79                .domain_name(server_name));
80        };
81
82        // Use the provided certificate
83        client_config(cert_path, address, self.domain)
84    }
85}
86
87/// Build the client-side TLS configuration from a file of trust anchors.
88///
89/// # Arguments
90/// * `cert_path` - path to the trust anchor to verify the orchestrator against
91/// * `address` - the orchestrator address (`-a`), whose host names the orchestrator
92/// * `tls_domain` - the name to verify the orchestrator as, overriding the address (`--tls_domain`)
93///
94/// # Returns
95/// A tonic [`ClientTlsConfig`] that trusts the certificates in `cert_path`.
96///
97/// # Errors
98/// If the certificate file cannot be read or holds no PEM certificate.
99fn client_config(
100    cert_path: &str,
101    address: &str,
102    tls_domain: Option<&str>,
103) -> Result<ClientTlsConfig, Box<dyn Error>> {
104    let pem = fs::read(cert_path)
105        .map_err(|e| format!("Unable to read certificate at {cert_path}: {e}"))?;
106    let certificates = certificates(&pem, cert_path)?;
107
108    let (server_name, source) = server_name(&certificates, address, tls_domain);
109
110    info!(
111        "[TLS] Trusting {} certificate(s) from {cert_path}, authenticating orchestrator as '{server_name}' (from {})",
112        certificates.len(),
113        source.as_str()
114    );
115
116    // Verifying an IP address requires the orchestrator's certificate to carry an IP SAN
117    if matches!(source, NameSource::Address) && server_name.parse::<IpAddr>().is_ok() {
118        warn!("[TLS] Authenticating an IP address requires an IP Subject Alternative Name.");
119    }
120
121    Ok(ClientTlsConfig::new()
122        .ca_certificate(Certificate::from_pem(pem))
123        .domain_name(server_name))
124}
125
126/// Load the orchestrator's TLS identity (certificate + private key).
127///
128/// The certificate file may hold a chain with intermediate CA certificates.
129///
130/// # Arguments
131/// * `cert_path` - path to the certificate (chain)
132/// * `key_path` - path to the private key, defaults to `cert_path` with a `.key` extension
133///
134/// # Returns
135/// A tonic [`Identity`] to be used in the server's `ServerTlsConfig`.
136///
137/// # Panics
138/// If the certificate or private key cannot be read.
139pub fn server_identity(cert_path: &str, key_path: Option<&str>) -> Identity {
140    let key_path = key_path.map_or_else(|| default_key_path(cert_path), PathBuf::from);
141
142    let cert = fs::read(cert_path)
143        .unwrap_or_else(|e| panic!("Unable to read certificate at {cert_path}: {e}"));
144    let key = fs::read(&key_path)
145        .unwrap_or_else(|e| panic!("Unable to read private key at {}: {e}", key_path.display()));
146
147    info!(
148        "[TLS] Using certificate {cert_path} and private key {}",
149        key_path.display()
150    );
151
152    // Parse all provided PEM certificates.
153    match certificates(&cert, cert_path) {
154        Ok(certificates) => describe_served_chain(&certificates, cert_path),
155        Err(e) => warn!("[TLS] {e}"),
156    }
157
158    Identity::from_pem(cert, key)
159}
160
161/// The private key path implied by a certificate path (same name, `.key` extension).
162fn default_key_path(cert_path: &str) -> PathBuf {
163    Path::new(cert_path).with_extension("key")
164}
165
166/// Parse every PEM certificate in `pem`, in file order.
167///
168/// # Errors
169/// If the file holds no PEM certificate (e.g. it is a private key, or DER rather than PEM).
170fn certificates(pem: &[u8], cert_path: &str) -> Result<Vec<Pem>, Box<dyn Error>> {
171    let certificates: Vec<Pem> = Pem::iter_from_buffer(pem)
172        .filter_map(Result::ok)
173        .filter(|pem| pem.label == CERTIFICATE_LABEL)
174        .collect();
175
176    if certificates.is_empty() {
177        return Err(format!("No PEM certificate found in {cert_path}.").into());
178    }
179
180    Ok(certificates)
181}
182
183/// Determine the name to authenticate the orchestrator as.
184///
185/// In order:
186/// 1. `--tls_domain`, when given.
187/// 2. The host part of the orchestrator address, when it is a hostname.
188///    a. The Subject Alternative Name of a pinned self-signed certificate.
189///    b. The address itself, an IP address verified against an IP SAN.
190fn server_name(
191    certificates: &[Pem],
192    address: &str,
193    tls_domain: Option<&str>,
194) -> (String, NameSource) {
195    if let Some(domain) = tls_domain {
196        return (domain.to_owned(), NameSource::Flag);
197    }
198
199    let host = host_of(address);
200    if host.parse::<IpAddr>().is_err() {
201        return (host.to_owned(), NameSource::Address);
202    }
203
204    pinned_self_signed_name(certificates).map_or_else(
205        || (host.to_owned(), NameSource::Address),
206        |name| (name, NameSource::Pinned),
207    )
208}
209
210/// Get the hostname/address part of an `address:port` value
211fn host_of(address: &str) -> &str {
212    // Strip brackets (IPv6)
213    if let Some(rest) = address.strip_prefix('[') {
214        return rest.split_once(']').map_or(rest, |(host, _)| host);
215    }
216
217    address.rsplit_once(':').map_or(address, |(host, _)| host)
218}
219
220/// Get the name pinned in self-signed certificates.
221fn pinned_self_signed_name(certificates: &[Pem]) -> Option<String> {
222    // Must be a single PEM certificate
223    let [pinned] = certificates else {
224        return None;
225    };
226
227    let certificate = pinned.parse_x509().ok()?;
228    if certificate.subject().as_raw() != certificate.issuer().as_raw() {
229        // Issued by a different certificate
230        return None;
231    }
232
233    subject_alternative_names(&certificate).into_iter().next()
234}
235
236/// Get the SAN values (hostnames and IP addresses) a certificate is valid for.
237fn subject_alternative_names(certificate: &X509Certificate) -> Vec<String> {
238    let names = certificate
239        .subject_alternative_name()
240        .ok()
241        .flatten()
242        .map(|san| san.value.general_names.as_slice())
243        .unwrap_or_default();
244
245    let dns = names.iter().filter_map(|name| match name {
246        GeneralName::DNSName(dns) => Some((*dns).to_owned()),
247        _ => None,
248    });
249    let ip = names.iter().filter_map(|name| match name {
250        GeneralName::IPAddress(bytes) => to_ip(bytes).map(|ip| ip.to_string()),
251        _ => None,
252    });
253
254    dns.chain(ip).collect()
255}
256
257/// Parse Orchestrator certificate and warn for possibly malformed certificates.
258fn describe_served_chain(certificates: &[Pem], cert_path: &str) {
259    // Get the first certificate (in case it is a chain or bundle)
260    let Ok(certificate) = certificates[0].parse_x509() else {
261        warn!("[TLS] Unable to parse the certificate at {cert_path}");
262        return;
263    };
264
265    // Get allowed SAN values
266    let names = subject_alternative_names(&certificate);
267    if names.is_empty() {
268        warn!(
269            "[TLS] The certificate at {cert_path} has no Subject Alternative Name (SAN), \
270            regenerate it with e.g. -addext \"subjectAltName=DNS:orchestrator.example.com\""
271        );
272    } else {
273        info!("[TLS] Serving a certificate valid for {}", names.join(", "));
274    }
275
276    let is_self_signed = certificate.subject().as_raw() == certificate.issuer().as_raw();
277    if is_self_signed {
278        info!("[TLS] The certificate is self-signed, clients must trust it directly (--tls)");
279    } else if certificates.len() == 1 {
280        // Single certificate that is not self-signed -> clients must trust the CA.
281        info!(
282            "[TLS] The certificate was issued by '{}', clients must trust that CA (--tls); \
283            if it is an intermediate CA, append its certificate to {cert_path} (a full chain, \
284            leaf first), or clients will reject the connection with 'UnknownIssuer'",
285            certificate.issuer()
286        );
287    } else {
288        info!(
289            "[TLS] Serving a chain of {} certificates, issued by '{}'",
290            certificates.len(),
291            certificate.issuer()
292        );
293    }
294}
295
296/// Convert the raw bytes of an IP address SAN into an [`IpAddr`].
297fn to_ip(bytes: &[u8]) -> Option<IpAddr> {
298    match bytes.len() {
299        4 => Some(IpAddr::from(<[u8; 4]>::try_from(bytes).ok()?)),
300        16 => Some(IpAddr::from(<[u8; 16]>::try_from(bytes).ok()?)),
301        _ => None,
302    }
303}