use std::path::{Path, PathBuf};
use std::sync::Arc;
use boatramp_acme::dns::DnsProvider;
use boatramp_acme::{ManualDnsProvider, preview_wildcard};
use clap::ValueEnum;
#[derive(Debug, thiserror::Error)]
#[allow(clippy::large_enum_variant)]
pub enum Error {
#[error("DNS provider: env var {0} is not set")]
EnvVarNotSet(String),
#[error("reading OCI_PRIVATE_KEY_FILE: {0}")]
OciKeyRead(#[source] std::io::Error),
#[error("{0}")]
Oci(String),
#[error("no certificates in PEM chain")]
NoCertificates,
#[error("no private key in PEM")]
NoPrivateKey,
#[error("loading certificate for {pattern}: {source}")]
LoadingCert {
pattern: String,
#[source]
source: Box<Self>,
},
#[error(transparent)]
Acme(#[from] boatramp_acme::acme::AcmeError),
#[error(transparent)]
Rustls(#[from] rustls::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum DnsProviderKind {
Manual,
Cloudflare,
Route53,
Oci,
#[value(name = "digitalocean", alias = "do")]
DigitalOcean,
Hetzner,
Ns1,
Dnsimple,
#[value(name = "gcp-dns", alias = "gcp")]
GcpDns,
#[value(name = "azure-dns", alias = "azure")]
AzureDns,
Akamai,
}
fn env(var: &str) -> Result<String> {
std::env::var(var).map_err(|_| Error::EnvVarNotSet(var.to_string()))
}
pub async fn build_provider(kind: DnsProviderKind) -> Result<Box<dyn DnsProvider>> {
build_provider_opts(kind, false).await
}
pub fn cloudflare_dns_from_env(proxied: bool) -> Result<boatramp_acme::cloudflare::CloudflareDns> {
Ok(boatramp_acme::cloudflare::CloudflareDns::new(
env("CLOUDFLARE_ZONE_ID")?,
env("CLOUDFLARE_API_TOKEN")?,
)
.with_proxied(proxied))
}
pub async fn build_provider_opts(
kind: DnsProviderKind,
proxied: bool,
) -> Result<Box<dyn DnsProvider>> {
Ok(match kind {
DnsProviderKind::Manual => Box::new(ManualDnsProvider::new()),
DnsProviderKind::Cloudflare => Box::new(cloudflare_dns_from_env(proxied)?),
DnsProviderKind::Route53 => Box::new(
boatramp_acme::route53::Route53Dns::from_env(env("ROUTE53_HOSTED_ZONE_ID")?).await,
),
DnsProviderKind::Oci => {
let pem =
std::fs::read_to_string(env("OCI_PRIVATE_KEY_FILE")?).map_err(Error::OciKeyRead)?;
Box::new(
boatramp_acme::oci::OciDns::new(
&env("OCI_REGION")?,
env("OCI_ZONE")?,
env("OCI_KEY_ID")?,
&pem,
)
.map_err(|e| Error::Oci(e.to_string()))?,
)
}
DnsProviderKind::DigitalOcean => {
Box::new(boatramp_acme::digitalocean::DigitalOceanDns::new(
env("DIGITALOCEAN_DOMAIN")?,
env("DIGITALOCEAN_TOKEN")?,
))
}
DnsProviderKind::Hetzner => Box::new(boatramp_acme::hetzner::HetznerDns::new(
env("HETZNER_ZONE_ID")?,
env("HETZNER_ZONE")?,
env("HETZNER_DNS_TOKEN")?,
)),
DnsProviderKind::Ns1 => Box::new(boatramp_acme::ns1::Ns1Dns::new(
env("NS1_ZONE")?,
env("NS1_API_KEY")?,
)),
DnsProviderKind::Dnsimple => Box::new(boatramp_acme::dnsimple::DnsimpleDns::new(
env("DNSIMPLE_ACCOUNT_ID")?,
env("DNSIMPLE_ZONE")?,
env("DNSIMPLE_TOKEN")?,
)),
DnsProviderKind::GcpDns => Box::new(boatramp_acme::gcp_dns::GcpDns::new(
env("GCP_DNS_PROJECT")?,
env("GCP_DNS_ZONE")?,
env("GCP_ACCESS_TOKEN")?,
)),
DnsProviderKind::AzureDns => Box::new(boatramp_acme::azure_dns::AzureDns::new(
env("AZURE_SUBSCRIPTION_ID")?,
env("AZURE_RESOURCE_GROUP")?,
env("AZURE_DNS_ZONE")?,
env("AZURE_ACCESS_TOKEN")?,
)),
DnsProviderKind::Akamai => Box::new(boatramp_acme::akamai::AkamaiDns::new(
env("AKAMAI_HOST")?,
env("AKAMAI_CLIENT_TOKEN")?,
env("AKAMAI_CLIENT_SECRET")?,
env("AKAMAI_ACCESS_TOKEN")?,
env("AKAMAI_ZONE")?,
)),
})
}
pub fn sni_matches(pattern: &str, sni: &str) -> bool {
if let Some(suffix) = pattern.strip_prefix("*.") {
match sni.strip_suffix(suffix).and_then(|p| p.strip_suffix('.')) {
Some(label) => !label.is_empty() && !label.contains('.'),
None => false,
}
} else {
pattern.eq_ignore_ascii_case(sni)
}
}
fn cache_paths(cache_dir: &Path, domain: &str) -> (PathBuf, PathBuf, PathBuf) {
let safe = domain.replace('*', "_wildcard_");
let dir = cache_dir.join(safe);
(
dir.join("cert.pem"),
dir.join("key.pem"),
dir.join("issued_at"),
)
}
const RENEW_AFTER_SECS: u64 = 60 * 24 * 3600;
use boatramp_acme::acme::{CertRequest, IssuedCert};
use boatramp_core::time::now_unix;
fn load_fresh(cache_dir: &Path, domain: &str) -> Result<Option<IssuedCert>> {
let (cert_p, key_p, stamp_p) = cache_paths(cache_dir, domain);
if !cert_p.exists() || !key_p.exists() || !stamp_p.exists() {
return Ok(None);
}
let issued_at: u64 = std::fs::read_to_string(&stamp_p)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
if now_unix().saturating_sub(issued_at) >= RENEW_AFTER_SECS {
return Ok(None);
}
Ok(Some(IssuedCert {
certificate_pem: std::fs::read_to_string(&cert_p)?,
private_key_pem: std::fs::read_to_string(&key_p)?,
}))
}
fn write_cache(cache_dir: &Path, domain: &str, cert: &IssuedCert) -> Result<()> {
let (cert_p, key_p, stamp_p) = cache_paths(cache_dir, domain);
if let Some(parent) = cert_p.parent() {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)?;
}
#[cfg(not(unix))]
std::fs::create_dir_all(parent)?;
}
std::fs::write(&cert_p, &cert.certificate_pem)?;
write_private_key(&key_p, &cert.private_key_pem)?;
std::fs::write(&stamp_p, now_unix().to_string())?;
Ok(())
}
fn write_private_key(key_p: &Path, private_key_pem: &str) -> Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(key_p)?;
f.write_all(private_key_pem.as_bytes())?;
}
#[cfg(not(unix))]
std::fs::write(key_p, private_key_pem)?;
Ok(())
}
pub async fn obtain_or_load(
domain: &str,
base: &CertRequest,
provider: &dyn DnsProvider,
cache_dir: &Path,
) -> Result<IssuedCert> {
if let Some(cert) = load_fresh(cache_dir, domain)? {
tracing::info!(domain, "using cached certificate");
return Ok(cert);
}
tracing::info!(domain, "obtaining certificate via ACME DNS-01");
let request = CertRequest {
directory_url: base.directory_url.clone(),
contact_email: base.contact_email.clone(),
domains: vec![domain.to_string()],
dns_ttl: base.dns_ttl,
propagation_delay: base.propagation_delay,
timeout: base.timeout,
};
let cert = boatramp_acme::acme::obtain_certificate(&request, provider).await?;
boatramp_server::server_metrics().record_cert_renewal();
write_cache(cache_dir, domain, &cert)?;
Ok(cert)
}
pub fn server_domains(acme_domains: &[String], wildcard_preview: bool) -> Vec<String> {
let mut out = Vec::new();
for domain in acme_domains {
out.push(domain.clone());
if wildcard_preview {
out.push(preview_wildcard(domain));
}
}
out
}
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls::sign::CertifiedKey;
#[derive(Debug)]
struct SniCertResolver {
entries: Vec<(String, Arc<CertifiedKey>)>,
}
impl SniCertResolver {
fn resolve_sni(&self, sni: &str) -> Option<Arc<CertifiedKey>> {
self.entries
.iter()
.find(|(pattern, _)| sni_matches(pattern, sni))
.map(|(_, key)| key.clone())
}
}
impl ResolvesServerCert for SniCertResolver {
fn resolve(&self, hello: ClientHello) -> Option<Arc<CertifiedKey>> {
self.resolve_sni(hello.server_name()?)
}
}
#[cfg(all(feature = "acme-dns", feature = "cluster"))]
#[derive(Debug)]
struct CompositeResolver {
static_resolver: Arc<SniCertResolver>,
on_demand: Arc<crate::on_demand_tls::OnDemandResolver>,
default_cert: Option<Arc<CertifiedKey>>,
}
#[cfg(all(feature = "acme-dns", feature = "cluster"))]
impl ResolvesServerCert for CompositeResolver {
fn resolve(&self, hello: ClientHello) -> Option<Arc<CertifiedKey>> {
let Some(sni) = hello.server_name() else {
return self.default_cert.clone();
};
let is_acme_alpn = crate::on_demand_tls::is_acme_tls_alpn(&hello);
self.static_resolver
.resolve_sni(sni)
.or_else(|| self.on_demand.resolve_sni(sni, is_acme_alpn))
}
}
pub(crate) fn certified_key_from_pem(cert_pem: &str, key_pem: &str) -> Result<CertifiedKey> {
let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
rustls_pemfile::certs(&mut cert_pem.as_bytes()).collect::<std::result::Result<_, _>>()?;
if certs.is_empty() {
return Err(Error::NoCertificates);
}
let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())?.ok_or(Error::NoPrivateKey)?;
let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key)?;
Ok(CertifiedKey::new(certs, signing_key))
}
fn sni_resolver(entries: Vec<(String, IssuedCert)>) -> Result<Arc<SniCertResolver>> {
let mut resolved = Vec::new();
for (pattern, cert) in entries {
let key = certified_key_from_pem(&cert.certificate_pem, &cert.private_key_pem).map_err(
|source| Error::LoadingCert {
pattern: pattern.clone(),
source: Box::new(source),
},
)?;
resolved.push((pattern, Arc::new(key)));
}
Ok(Arc::new(SniCertResolver { entries: resolved }))
}
pub fn build_server_config(entries: Vec<(String, IssuedCert)>) -> Result<rustls::ServerConfig> {
let mut config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(sni_resolver(entries)?);
config.alpn_protocols = boatramp_server::alpn_h1_h2();
Ok(config)
}
#[cfg(feature = "http3")]
pub fn build_server_configs(
entries: Vec<(String, IssuedCert)>,
) -> Result<(rustls::ServerConfig, rustls::ServerConfig)> {
let resolver = sni_resolver(entries)?;
let mut tcp = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(resolver.clone());
tcp.alpn_protocols = boatramp_server::alpn_h1_h2();
let mut h3 = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(resolver);
h3.alpn_protocols = vec![b"h3".to_vec()];
Ok((tcp, h3))
}
#[cfg(all(feature = "acme-dns", feature = "cluster"))]
fn server_config_from(resolver: Arc<dyn ResolvesServerCert>) -> rustls::ServerConfig {
let mut config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(resolver);
config.alpn_protocols = boatramp_server::alpn_h1_h2();
config
}
#[cfg(all(feature = "acme-dns", feature = "cluster"))]
pub fn build_server_config_composite(
entries: Vec<(String, IssuedCert)>,
on_demand: Arc<crate::on_demand_tls::OnDemandResolver>,
) -> Result<rustls::ServerConfig> {
let resolver: Arc<dyn ResolvesServerCert> = Arc::new(CompositeResolver {
static_resolver: sni_resolver(entries)?,
on_demand,
default_cert: None,
});
Ok(server_config_from(resolver))
}
#[cfg(all(feature = "acme-dns", feature = "cluster"))]
pub fn build_listener_server_config_composite(
cert_pem: &str,
key_pem: &str,
server_names: Option<&[String]>,
on_demand: Arc<crate::on_demand_tls::OnDemandResolver>,
) -> Result<rustls::ServerConfig> {
let own = Arc::new(certified_key_from_pem(cert_pem, key_pem)?);
let entries = server_names
.unwrap_or(&[])
.iter()
.map(|name| (name.clone(), own.clone()))
.collect();
let resolver: Arc<dyn ResolvesServerCert> = Arc::new(CompositeResolver {
static_resolver: Arc::new(SniCertResolver { entries }),
on_demand,
default_cert: Some(own),
});
Ok(rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(resolver))
}
#[cfg(all(feature = "http3", feature = "acme-dns", feature = "cluster"))]
pub fn build_server_configs_composite(
entries: Vec<(String, IssuedCert)>,
on_demand: Arc<crate::on_demand_tls::OnDemandResolver>,
) -> Result<(rustls::ServerConfig, rustls::ServerConfig)> {
let resolver: Arc<dyn ResolvesServerCert> = Arc::new(CompositeResolver {
static_resolver: sni_resolver(entries)?,
on_demand,
default_cert: None,
});
let tcp = server_config_from(resolver.clone());
let mut h3 = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(resolver);
h3.alpn_protocols = vec![b"h3".to_vec()];
Ok((tcp, h3))
}
#[cfg(all(feature = "acme-dns", feature = "cluster"))]
pub fn build_server_config_maybe(
entries: Vec<(String, IssuedCert)>,
on_demand: Option<Arc<crate::on_demand_tls::OnDemandResolver>>,
) -> Result<rustls::ServerConfig> {
match on_demand {
Some(r) => build_server_config_composite(entries, r),
None => build_server_config(entries),
}
}
#[cfg(all(feature = "http3", feature = "acme-dns", feature = "cluster"))]
pub fn build_server_configs_maybe(
entries: Vec<(String, IssuedCert)>,
on_demand: Option<Arc<crate::on_demand_tls::OnDemandResolver>>,
) -> Result<(rustls::ServerConfig, rustls::ServerConfig)> {
match on_demand {
Some(r) => build_server_configs_composite(entries, r),
None => build_server_configs(entries),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "http3")]
#[test]
fn build_server_configs_sets_h3_alpn() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (tcp, h3) = build_server_configs(Vec::new()).unwrap();
assert_eq!(
tcp.alpn_protocols,
vec![b"h2".to_vec(), b"http/1.1".to_vec()],
"TCP config advertises the serving ALPN"
);
assert_eq!(
h3.alpn_protocols,
vec![b"h3".to_vec()],
"h3 config advertises the h3 ALPN"
);
}
#[test]
fn wildcard_matches_one_label_only() {
assert!(sni_matches(
"*.deploy.example.com",
"abc.deploy.example.com"
));
assert!(!sni_matches(
"*.deploy.example.com",
"a.b.deploy.example.com"
));
assert!(!sni_matches("*.deploy.example.com", "abc.deploy.other.com"));
assert!(!sni_matches("*.deploy.example.com", ".deploy.example.com"));
}
#[test]
fn exact_match_is_case_insensitive() {
assert!(sni_matches("example.com", "Example.COM"));
assert!(!sni_matches("example.com", "www.example.com"));
}
#[test]
fn cache_paths_encode_wildcard() {
let (cert, key, stamp) = cache_paths(Path::new("/c"), "*.deploy.example.com");
assert!(
cert.to_string_lossy()
.contains("_wildcard_.deploy.example.com")
);
assert!(key.ends_with("key.pem"));
assert!(stamp.ends_with("issued_at"));
}
#[cfg(unix)]
#[test]
fn write_cache_locks_down_key_and_dir() {
use std::os::unix::fs::PermissionsExt;
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let scratch = Scratch(std::env::temp_dir().join(format!(
"boatramp-acme-test-{}-{}",
std::process::id(),
now_unix()
)));
std::fs::create_dir_all(&scratch.0).unwrap();
let cert = IssuedCert {
certificate_pem: "-----BEGIN CERTIFICATE-----\nx\n-----END CERTIFICATE-----\n"
.to_string(),
private_key_pem: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n"
.to_string(),
};
write_cache(&scratch.0, "example.com", &cert).unwrap();
let (_cert_p, key_p, _stamp_p) = cache_paths(&scratch.0, "example.com");
let key_mode = std::fs::metadata(&key_p).unwrap().permissions().mode() & 0o777;
assert_eq!(key_mode, 0o600, "private key must be owner read/write only");
let dir_mode = std::fs::metadata(key_p.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(dir_mode, 0o700, "per-domain cache dir must be owner-only");
}
#[cfg(feature = "cluster")]
mod listener_composite {
use super::*;
use rustls::client::danger::{
HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier,
};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{ClientConnection, DigitallySignedStruct, ServerConnection, SignatureScheme};
fn self_signed(name: &str) -> (String, String) {
let kp = rcgen::KeyPair::generate().unwrap();
let params = rcgen::CertificateParams::new(vec![name.to_string()]).unwrap();
let cert = params.self_signed(&kp).unwrap();
(cert.pem(), kp.serialize_pem())
}
fn leaf_der(cert_pem: &str) -> Vec<u8> {
rustls_pemfile::certs(&mut cert_pem.as_bytes())
.next()
.unwrap()
.unwrap()
.to_vec()
}
#[derive(Debug)]
struct AcceptAny;
impl ServerCertVerifier for AcceptAny {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
_now: UnixTime,
) -> std::result::Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &CertificateDer<'_>,
_dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
Ok(HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
fn drive(
client: &mut ClientConnection,
server: &mut ServerConnection,
) -> std::result::Result<(), rustls::Error> {
for _ in 0..32 {
let mut c2s = Vec::new();
while client.wants_write() {
client.write_tls(&mut c2s).unwrap();
}
if !c2s.is_empty() {
let mut rd: &[u8] = &c2s;
while !rd.is_empty() {
server.read_tls(&mut rd).unwrap();
}
server.process_new_packets()?;
}
let mut s2c = Vec::new();
while server.wants_write() {
server.write_tls(&mut s2c).unwrap();
}
if !s2c.is_empty() {
let mut rd: &[u8] = &s2c;
while !rd.is_empty() {
client.read_tls(&mut rd).unwrap();
}
client.process_new_packets()?;
}
if !client.is_handshaking() && !server.is_handshaking() {
return Ok(());
}
}
Err(rustls::Error::General("handshake did not complete".into()))
}
fn served_cert(
config: &std::sync::Arc<rustls::ServerConfig>,
name: ServerName<'static>,
) -> Option<Vec<u8>> {
let client_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(std::sync::Arc::new(AcceptAny))
.with_no_client_auth();
let mut client =
ClientConnection::new(std::sync::Arc::new(client_config), name).unwrap();
let mut server = ServerConnection::new(config.clone()).unwrap();
drive(&mut client, &mut server).ok()?;
client
.peer_certificates()
.and_then(|c| c.first())
.map(|c| c.as_ref().to_vec())
}
#[test]
fn composite_serves_own_cached_and_refuses_unknown() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (own_cert, own_key) = self_signed("own.example.test");
let (cached_cert, cached_key) = self_signed("cached.example.test");
let cache = std::sync::Arc::new(crate::on_demand_tls::CertCache::default());
cache.insert_issued(
"cached.example.test",
std::sync::Arc::new(certified_key_from_pem(&cached_cert, &cached_key).unwrap()),
);
let on_demand =
std::sync::Arc::new(crate::on_demand_tls::OnDemandResolver::from_cache(cache));
let server_names = vec!["own.example.test".to_string()];
let config = std::sync::Arc::new(
build_listener_server_config_composite(
&own_cert,
&own_key,
Some(&server_names),
on_demand,
)
.unwrap(),
);
assert!(
config.alpn_protocols.is_empty(),
"listener composite must advertise no ALPN (protocol-agnostic)"
);
let own = ServerName::try_from("own.example.test").unwrap();
let cached = ServerName::try_from("cached.example.test").unwrap();
let unknown = ServerName::try_from("unseeded.example.test").unwrap();
let no_sni = ServerName::try_from("127.0.0.1").unwrap();
assert_eq!(
served_cert(&config, own).as_deref(),
Some(leaf_der(&own_cert).as_slice()),
"own configured server_name is served the listener's static cert"
);
assert_eq!(
served_cert(&config, cached).as_deref(),
Some(leaf_der(&cached_cert).as_slice()),
"a verified SNI in the on-demand cache is served that cert"
);
assert_eq!(
served_cert(&config, unknown),
None,
"an unknown (unverified, uncached) SNI is refused — no cert, failed handshake"
);
assert_eq!(
served_cert(&config, no_sni).as_deref(),
Some(leaf_der(&own_cert).as_slice()),
"an SNI-less handshake is served the listener's own default cert"
);
println!(
"LISTENER ON-DEMAND TLS COMPOSITE OK: own+cached served, unknown refused, no-SNI default"
);
}
}
}