crates/graphar-falkordb/src/tls.rs
//! **Mutual TLS to FalkorDB** — the client identity and the ONE function that turns a URL
//! plus an optional identity into a [`redis::Client`].
//!
//! It lives HERE, in the loader crate, and not in `graphar-flight` where it was born:
//! `graphar-flight` depends on this crate, so the read side (`FalkorExecutor`) and the write
//! side ([`FalkorDbLoader`](crate::FalkorDbLoader)) can only share one TLS path if it is
//! down here. `graphar_flight::FalkorTls` is a re-export of this type, so every existing
//! caller keeps its path.
//!
//! A server that runs `tls-auth-clients yes` (+ `tls-auth-clients-user CN`) accepts nothing
//! else: a plain `redis://` connect is refused at the handshake, and the certificate's CN is
//! the ACL user — no AUTH is sent.
use std::path::Path;
/// A **mutual-TLS client identity** for a `rediss://` FalkorDB connection — the client leaf
/// certificate + its private key (both PEM), and an optional CA (PEM) to verify the server
/// with. `None` in place of this ⇒ the plain `redis://` path, unchanged.
///
/// `Clone` because a load's parameters are cloned onto the worker that runs it. `Debug`
/// prints byte counts only — never the key, so a `{:?}` of the parameters in a log cannot
/// leak it.
#[derive(Clone)]
pub struct FalkorTls {
/// The client leaf certificate, PEM (a chain after it is fine).
pub client_cert_pem: Vec<u8>,
/// The matching client private key, PEM.
pub client_key_pem: Vec<u8>,
/// An optional CA (PEM) used to verify the server; `None` ⇒ the system root store.
pub ca_pem: Option<Vec<u8>>,
}
impl std::fmt::Debug for FalkorTls {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FalkorTls")
.field("client_cert_pem", &format_args!("{} bytes", self.client_cert_pem.len()))
.field("client_key_pem", &format_args!("<redacted, {} bytes>", self.client_key_pem.len()))
.field("ca_pem", &self.ca_pem.as_ref().map(|c| format!("{} bytes", c.len())))
.finish()
}
}
impl FalkorTls {
/// Read the three PEM files a CLI is handed (`--tls-cert`, `--tls-key`, `--tls-ca`). The
/// error names the file that could not be read.
pub fn from_files(cert: &Path, key: &Path, ca: Option<&Path>) -> std::io::Result<Self> {
let read = |p: &Path| {
std::fs::read(p).map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", p.display())))
};
Ok(Self {
client_cert_pem: read(cert)?,
client_key_pem: read(key)?,
ca_pem: ca.map(read).transpose()?,
})
}
}
/// **The one place knut builds a FalkorDB client.** `None` ⇒ `redis::Client::open(url)`,
/// byte for byte the historical path; `Some` ⇒ the rustls client with the identity presented
/// and `ca_pem` (or the system roots) verifying the server. The URL must be `rediss://` for
/// the identity to be used at all.
pub fn client(url: &str, tls: Option<FalkorTls>) -> redis::RedisResult<redis::Client> {
match tls {
None => redis::Client::open(url),
Some(id) => {
// rustls 0.23 chooses a crypto provider by itself only when exactly ONE is
// compiled in. A program that links both ring and aws-lc-rs - korp does - panics
// on the first handshake ("no process-level CryptoProvider"), which is how korp's
// fill into an mTLS FalkorDB died before sending a byte. Install ring once,
// process-wide; a program that already chose a provider keeps its choice.
let _ = rustls::crypto::ring::default_provider().install_default();
redis::Client::build_with_tls(
url,
redis::TlsCertificates {
client_tls: Some(redis::ClientTlsConfig {
client_cert: id.client_cert_pem,
client_key: id.client_key_pem,
}),
root_cert: id.ca_pem,
},
)
}
}
}