use std::path::{Path, PathBuf};
use std::sync::Arc;
use boatramp_acme::dns::DnsProvider;
use boatramp_acme::{preview_wildcard, ManualDnsProvider};
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 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(
boatramp_acme::cloudflare::CloudflareDns::new(
env("CLOUDFLARE_ZONE_ID")?,
env("CLOUDFLARE_API_TOKEN")?,
)
.with_proxied(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 ResolvesServerCert for SniCertResolver {
fn resolve(&self, hello: ClientHello) -> Option<Arc<CertifiedKey>> {
let sni = hello.server_name()?;
self.entries
.iter()
.find(|(pattern, _)| sni_matches(pattern, sni))
.map(|(_, key)| key.clone())
}
}
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> {
Ok(rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(sni_resolver(entries)?))
}
#[cfg(feature = "http3")]
pub fn build_server_configs(
entries: Vec<(String, IssuedCert)>,
) -> Result<(rustls::ServerConfig, rustls::ServerConfig)> {
let resolver = sni_resolver(entries)?;
let tcp = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(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(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!(tcp.alpn_protocols.is_empty(), "TCP config forces no 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");
}
}