use crate::FaucetError;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub fn ensure_crypto_provider() {
static INSTALLED: std::sync::Once = std::sync::Once::new();
INSTALLED.call_once(|| {
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
});
}
#[derive(Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
pub struct TlsClientConfig {
pub client_cert: Option<String>,
pub client_key: Option<String>,
#[schemars(extend("x-faucet-resource" = "path"))]
pub client_identity_pkcs12: Option<String>,
pub pkcs12_password: Option<String>,
pub min_version: Option<String>,
}
impl std::fmt::Debug for TlsClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let secret = |v: &Option<String>| v.as_ref().map(|_| "***");
f.debug_struct("TlsClientConfig")
.field("client_cert", &self.client_cert)
.field("client_key", &secret(&self.client_key))
.field("client_identity_pkcs12", &self.client_identity_pkcs12)
.field("pkcs12_password", &secret(&self.pkcs12_password))
.field("min_version", &self.min_version)
.finish()
}
}
impl TlsClientConfig {
pub fn validate(&self) -> Result<(), FaucetError> {
let has_pem = self.client_cert.is_some() || self.client_key.is_some();
let has_p12 = self.client_identity_pkcs12.is_some();
if has_pem && has_p12 {
return Err(FaucetError::Config(
"tls: specify either a PEM pair (client_cert + client_key) or \
client_identity_pkcs12, not both"
.into(),
));
}
if !has_pem && !has_p12 {
return Err(FaucetError::Config(
"tls: provide a PEM pair (client_cert + client_key) or \
client_identity_pkcs12"
.into(),
));
}
if has_pem && (self.client_cert.is_none() || self.client_key.is_none()) {
return Err(FaucetError::Config(
"tls: client_cert and client_key must be provided together".into(),
));
}
if let Some(v) = &self.min_version
&& !matches!(v.as_str(), "1.2" | "1.3")
{
return Err(FaucetError::Config(format!(
"tls: unsupported min_version {v:?} (expected \"1.2\" or \"1.3\")"
)));
}
Ok(())
}
}
#[derive(Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TlsOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(extend("x-faucet-resource" = "path"))]
pub ca_cert_path: Option<std::path::PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_cert_pem: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(extend("x-faucet-resource" = "path"))]
pub client_cert_path: Option<std::path::PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(extend("x-faucet-resource" = "path"))]
pub client_key_path: Option<std::path::PathBuf>,
#[serde(default)]
pub insecure_skip_verify: bool,
}
impl std::fmt::Debug for TlsOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TlsOptions")
.field("ca_cert_path", &self.ca_cert_path)
.field("ca_cert_pem", &self.ca_cert_pem.as_ref().map(|_| "<pem>"))
.field("client_cert_path", &self.client_cert_path)
.field("client_key_path", &self.client_key_path)
.field("insecure_skip_verify", &self.insecure_skip_verify)
.finish()
}
}
#[derive(Clone, Default, PartialEq, Eq)]
pub struct LoadedTls {
pub ca_pem: Option<Vec<u8>>,
pub client: Option<ClientPem>,
pub insecure_skip_verify: bool,
}
impl std::fmt::Debug for LoadedTls {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoadedTls")
.field("ca_pem", &self.ca_pem.as_ref().map(Vec::len))
.field("client", &self.client.as_ref().map(|_| "<identity>"))
.field("insecure_skip_verify", &self.insecure_skip_verify)
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ClientPem {
pub cert: Vec<u8>,
pub key: Vec<u8>,
}
impl TlsOptions {
pub fn is_set(&self) -> bool {
*self != Self::default()
}
pub fn is_default(&self) -> bool {
!self.is_set()
}
pub fn validate(&self, connector: &str) -> Result<(), FaucetError> {
if self.ca_cert_path.is_some() && self.ca_cert_pem.is_some() {
return Err(FaucetError::Config(format!(
"{connector}: set `tls.ca_cert_path` or `tls.ca_cert_pem`, not both"
)));
}
if self
.ca_cert_pem
.as_deref()
.is_some_and(|p| !is_pem(p.as_bytes()))
{
return Err(FaucetError::Config(format!(
"{connector}: `tls.ca_cert_pem` is not a PEM certificate"
)));
}
if self.client_cert_path.is_some() != self.client_key_path.is_some() {
return Err(FaucetError::Config(format!(
"{connector}: `tls.client_cert_path` and `tls.client_key_path` must be set together"
)));
}
Ok(())
}
pub fn load(&self, connector: &str) -> Result<LoadedTls, FaucetError> {
ensure_crypto_provider();
self.validate(connector)?;
let read = |path: &std::path::Path, field: &str| -> Result<Vec<u8>, FaucetError> {
let bytes = std::fs::read(path).map_err(|e| {
FaucetError::Config(format!(
"{connector}: cannot read `tls.{field}` '{}': {e}",
path.display()
))
})?;
if !is_pem(&bytes) {
return Err(FaucetError::Config(format!(
"{connector}: `tls.{field}` '{}' is not a PEM file",
path.display()
)));
}
Ok(bytes)
};
let ca_pem = match (&self.ca_cert_path, &self.ca_cert_pem) {
(Some(p), _) => Some(read(p, "ca_cert_path")?),
(None, Some(pem)) => Some(pem.as_bytes().to_vec()),
(None, None) => None,
};
let client = match (&self.client_cert_path, &self.client_key_path) {
(Some(cert), Some(key)) => Some(ClientPem {
cert: read(cert, "client_cert_path")?,
key: read(key, "client_key_path")?,
}),
_ => None,
};
if self.insecure_skip_verify {
tracing::warn!(
connector,
"tls.insecure_skip_verify is on: the server certificate is NOT verified"
);
}
Ok(LoadedTls {
ca_pem,
client,
insecure_skip_verify: self.insecure_skip_verify,
})
}
}
fn is_pem(bytes: &[u8]) -> bool {
bytes.windows(11).any(|w| w == b"-----BEGIN ")
}
#[cfg(test)]
mod options_tests {
use super::*;
const PEM: &str = "-----BEGIN CERTIFICATE-----\nAA==\n-----END CERTIFICATE-----\n";
#[test]
fn default_is_unset_and_loads_nothing() {
let o = TlsOptions::default();
assert!(!o.is_set() && o.is_default());
assert_eq!(o.load("x").unwrap(), LoadedTls::default());
let o: TlsOptions = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(!o.is_set());
}
#[test]
fn unknown_keys_are_refused() {
let r = serde_json::from_value::<TlsOptions>(serde_json::json!({"ca": "x"}));
assert!(r.is_err());
}
#[test]
fn both_ca_sources_are_refused() {
let o = TlsOptions {
ca_cert_path: Some("/a".into()),
ca_cert_pem: Some(PEM.into()),
..Default::default()
};
let e = o.validate("redis").unwrap_err().to_string();
assert!(e.contains("redis") && e.contains("not both"), "{e}");
}
#[test]
fn inline_ca_must_be_pem() {
let o = TlsOptions {
ca_cert_pem: Some("nope".into()),
..Default::default()
};
assert!(
o.validate("nats")
.unwrap_err()
.to_string()
.contains("not a PEM")
);
}
#[test]
fn half_client_pair_is_refused() {
let o = TlsOptions {
client_cert_path: Some("/c".into()),
..Default::default()
};
assert!(
o.validate("es")
.unwrap_err()
.to_string()
.contains("together")
);
let o = TlsOptions {
client_key_path: Some("/k".into()),
..Default::default()
};
assert!(o.validate("es").is_err());
}
#[test]
fn load_reads_files_and_inline_pem() {
let dir = tempfile::tempdir().unwrap();
let ca = dir.path().join("ca.pem");
let cert = dir.path().join("c.pem");
let key = dir.path().join("k.pem");
std::fs::write(&ca, PEM).unwrap();
std::fs::write(&cert, PEM).unwrap();
std::fs::write(
&key,
"-----BEGIN PRIVATE KEY-----\nAA==\n-----END PRIVATE KEY-----\n",
)
.unwrap();
let o = TlsOptions {
ca_cert_path: Some(ca),
client_cert_path: Some(cert),
client_key_path: Some(key),
insecure_skip_verify: true,
..Default::default()
};
assert!(o.is_set());
let l = o.load("x").unwrap();
assert_eq!(l.ca_pem.as_deref(), Some(PEM.as_bytes()));
let client = l.client.as_ref().unwrap();
assert!(client.key.starts_with(b"-----BEGIN PRIVATE KEY"));
assert!(l.insecure_skip_verify);
let dbg = format!("{l:?}");
assert!(
!dbg.contains("PRIVATE") && dbg.contains("<identity>"),
"{dbg}"
);
let inline = TlsOptions {
ca_cert_pem: Some(PEM.into()),
..Default::default()
};
assert_eq!(inline.load("x").unwrap().ca_pem.unwrap(), PEM.as_bytes());
assert!(format!("{inline:?}").contains("<pem>"));
}
#[test]
fn load_reports_missing_and_non_pem_files() {
let dir = tempfile::tempdir().unwrap();
let missing = TlsOptions {
ca_cert_path: Some(dir.path().join("none.pem")),
..Default::default()
};
let e = missing.load("redis").unwrap_err().to_string();
assert!(e.contains("cannot read `tls.ca_cert_path`"), "{e}");
let junk = dir.path().join("junk");
std::fs::write(&junk, "hello").unwrap();
let o = TlsOptions {
ca_cert_path: Some(junk),
..Default::default()
};
assert!(
o.load("redis")
.unwrap_err()
.to_string()
.contains("not a PEM file")
);
}
#[test]
fn path_fields_are_declared_resources() {
let schema = serde_json::to_value(schemars::schema_for!(TlsOptions)).unwrap();
let config = serde_json::json!({
"ca_cert_path": "/a", "client_cert_path": "/b", "client_key_path": "/c"
});
let refs = crate::resolve::annotated_resources(&schema, &config);
assert_eq!(refs.len(), 3, "{refs:?}");
assert!(
refs.iter()
.all(|r| r.kind == crate::resolve::ResourceKind::Path)
);
}
}
#[cfg(test)]
mod tests {
#[test]
fn ensure_crypto_provider_installs_a_default_and_is_idempotent() {
super::ensure_crypto_provider();
super::ensure_crypto_provider();
assert!(rustls::crypto::CryptoProvider::get_default().is_some());
let _ = rustls::ClientConfig::builder();
}
use super::TlsClientConfig;
#[test]
fn the_pkcs12_path_is_a_declared_resource() {
let schema = serde_json::to_value(schemars::schema_for!(TlsClientConfig)).unwrap();
let config = serde_json::json!({"client_identity_pkcs12": "/etc/id.p12"});
let refs = crate::resolve::annotated_resources(&schema, &config);
assert!(
refs.iter()
.any(|r| r.kind == crate::resolve::ResourceKind::Path && r.value == "/etc/id.p12"),
"{refs:?}"
);
}
#[test]
fn debug_never_prints_key_material() {
let cfg = TlsClientConfig {
client_cert: Some("CERT".into()),
client_key: Some("PRIVATE-KEY-PEM".into()),
client_identity_pkcs12: None,
pkcs12_password: Some("p12-pass".into()),
min_version: None,
};
let text = format!("{cfg:?}");
assert!(
!text.contains("PRIVATE-KEY-PEM") && !text.contains("p12-pass"),
"{text}"
);
assert!(text.contains("CERT") && text.contains("***"), "{text}");
}
fn pem() -> TlsClientConfig {
TlsClientConfig {
client_cert: Some("cert".into()),
client_key: Some("key".into()),
..Default::default()
}
}
#[test]
fn valid_pem_pair_passes() {
assert!(pem().validate().is_ok());
}
#[test]
fn valid_pkcs12_passes() {
let c = TlsClientConfig {
client_identity_pkcs12: Some("/path/id.p12".into()),
pkcs12_password: Some("pw".into()),
..Default::default()
};
assert!(c.validate().is_ok());
}
#[test]
fn pem_and_pkcs12_together_is_rejected() {
let c = TlsClientConfig {
client_cert: Some("cert".into()),
client_key: Some("key".into()),
client_identity_pkcs12: Some("/path/id.p12".into()),
..Default::default()
};
assert!(c.validate().is_err());
}
#[test]
fn empty_config_is_rejected() {
assert!(TlsClientConfig::default().validate().is_err());
}
#[test]
fn half_pem_pair_is_rejected() {
let cert_only = TlsClientConfig {
client_cert: Some("cert".into()),
..Default::default()
};
assert!(cert_only.validate().is_err());
let key_only = TlsClientConfig {
client_key: Some("key".into()),
..Default::default()
};
assert!(key_only.validate().is_err());
}
#[test]
fn min_version_is_validated() {
let mut c = pem();
c.min_version = Some("1.3".into());
assert!(c.validate().is_ok());
c.min_version = Some("1.2".into());
assert!(c.validate().is_ok());
c.min_version = Some("1.1".into());
assert!(c.validate().is_err());
}
}