use std::future::Future;
use std::io::Write;
use std::ops::ControlFlow;
use std::path::Path;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use instant_acme::{
Account, AccountCredentials, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder,
OrderStatus, RetryPolicy,
};
use rustls::sign::CertifiedKey;
use tokio::task::JoinHandle;
use super::provider::{DnsProvider, RecordId};
use crate::RuntimeError;
use crate::config::AcmeBase;
use crate::runtime_state::LifecycleSignals;
use crate::tls::{CertStore, parse_certified_key};
const RENEWAL_THRESHOLD_DAYS: i64 = 30;
const RENEWAL_CHECK_INTERVAL: Duration = Duration::from_secs(12 * 60 * 60);
const LE_CERT_LIFETIME_DAYS: i64 = 90;
const CHALLENGE_TIMEOUT: Duration = Duration::from_secs(300);
pub struct AcmeDns01 {
base: AcmeBase,
}
impl AcmeDns01 {
pub fn new(tool_name: &str, domains: impl IntoIterator<Item = impl Into<Box<str>>>) -> Self {
Self {
base: AcmeBase::new(tool_name, domains),
}
}
pub fn email(mut self, email: impl Into<Box<str>>) -> Self {
self.base = self.base.email(email);
self
}
pub fn cache_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.base = self.base.cache_dir(path);
self
}
pub fn staging(mut self, staging: bool) -> Self {
self.base = self.base.staging(staging);
self
}
pub fn cache_path(&self) -> &Path {
self.base.cache_path()
}
pub async fn provision_cert<P: DnsProvider>(
&self,
provider: &P,
) -> Result<CertifiedKey, RuntimeError> {
match self
.provision_signalled(provider, &LifecycleSignals::current())
.await
{
ControlFlow::Continue(result) => result,
ControlFlow::Break(()) => Err(RuntimeError::Acme(
"certificate provisioning stopped: the runtime is shutting down".into(),
)),
}
}
pub(crate) async fn provision_signalled<P: DnsProvider>(
&self,
provider: &P,
signals: &LifecycleSignals,
) -> ControlFlow<(), Result<CertifiedKey, RuntimeError>> {
let mut order = match self.open_order(signals).await {
Ok(order) => order,
Err(stop) => return stop.into_flow(),
};
let mut created: Vec<RecordId> = Vec::new();
let outcome = run_guarded_order(&mut order, provider, &mut created, signals).await;
cleanup_txt_records(provider, &created).await;
match outcome {
Err(stop) => stop.into_flow(),
Ok((cert_pem, key_pem)) => {
self.cache_issued_cert(&cert_pem, &key_pem);
ControlFlow::Continue(parse_certified_key(cert_pem.as_bytes(), key_pem.as_bytes()))
}
}
}
async fn open_order(
&self,
signals: &LifecycleSignals,
) -> Result<instant_acme::Order, ProvisionStop> {
match signals.is_fired() {
true => return Err(ProvisionStop::Signalled),
false => {}
}
let account = self.load_or_create_account().await?;
let identifiers: Box<[Identifier]> = self
.base
.domains
.iter()
.map(|d| Identifier::Dns(d.to_string()))
.collect();
guarded_step(signals, account.new_order(&NewOrder::new(&identifiers))).await
}
pub fn load_cached_cert(&self) -> Result<Option<CertifiedKey>, RuntimeError> {
let cert_path = self.base.cache_dir.join("cert.pem");
let key_path = self.base.cache_dir.join("key.pem");
match cache_io(|| read_cached_pems(&cert_path, &key_path))? {
Some(pems) => Ok(Some(parse_certified_key(&pems.cert, &pems.key)?)),
None => Ok(None),
}
}
pub fn needs_renewal(&self) -> bool {
let expiry_path = self.base.cache_dir.join("expiry");
match cache_io(|| read_expiry_secs(&expiry_path)) {
Some(expiry_secs) => (expiry_secs - now_unix_secs()) / 86400 < RENEWAL_THRESHOLD_DAYS,
None => true,
}
}
pub fn spawn_renewal<P: DnsProvider + 'static>(
self,
provider: P,
store: CertStore,
) -> JoinHandle<()> {
tokio::spawn(dns01_renewal_loop(
self,
provider,
store,
LifecycleSignals::current(),
))
}
fn cache_issued_cert(&self, cert_pem: &str, key_pem: &str) {
if let Err(error) = self.cache_cert(cert_pem, key_pem) {
tracing::warn!(
%error,
"dns01 acme: certificate issued but not cached; it will be re-issued on restart"
);
}
}
fn cache_cert(&self, cert_pem: &str, key_pem: &str) -> Result<(), RuntimeError> {
cache_io(|| {
std::fs::create_dir_all(&self.base.cache_dir)?;
std::fs::write(self.base.cache_dir.join("cert.pem"), cert_pem)?;
let key_path = self.base.cache_dir.join("key.pem");
std::fs::write(&key_path, key_pem)?;
restrict_key_permissions(&key_path)?;
write_expiry(&self.base.cache_dir)
})
}
async fn load_or_create_account(&self) -> Result<Account, RuntimeError> {
let creds_path = self.base.cache_dir.join("account.json");
match cache_io(|| std::fs::read(&creds_path)) {
Ok(data) => {
let creds: AccountCredentials = serde_json::from_slice(&data).map_err(|e| {
RuntimeError::Acme(format!("failed to parse account credentials: {e}").into())
})?;
Account::builder()
.map_err(acme_err)?
.from_credentials(creds)
.await
.map_err(acme_err)
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let (account, creds) = create_account(&self.base.email, self.base.staging).await?;
self.save_new_credentials(&creds);
Ok(account)
}
Err(err) => Err(err.into()),
}
}
fn save_new_credentials(&self, credentials: &AccountCredentials) {
match self.save_credentials(credentials) {
Ok(()) => {}
Err(error) => tracing::error!(
%error,
"dns01 acme: account registered but credentials not saved; \
the next renewal pass will register a new account"
),
}
}
fn save_credentials(&self, credentials: &AccountCredentials) -> Result<(), RuntimeError> {
let json = serde_json::to_vec(credentials).map_err(|e| {
RuntimeError::Acme(format!("failed to serialize account credentials: {e}").into())
})?;
let account_path = self.base.cache_dir.join("account.json");
cache_io(|| {
std::fs::create_dir_all(&self.base.cache_dir)?;
write_credentials_file(&account_path, &json)
})
}
}
pub(crate) fn write_credentials_file(path: &Path, contents: &[u8]) -> Result<(), RuntimeError> {
let pending = PendingCredentials::create(path)?;
pending.commit(path, contents)?;
Ok(())
}
struct PendingCredentials {
file: std::fs::File,
path: PathBuf,
committed: bool,
}
impl PendingCredentials {
fn create(destination: &Path) -> Result<Self, std::io::Error> {
let parent = destination.parent().unwrap_or_else(|| Path::new("."));
let file_name = destination
.file_name()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("account.json");
for attempt in 0..16 {
let path = parent.join(format!(
".{file_name}.{:016x}.{attempt}.tmp",
crate::prng::next_u64(),
));
match open_private_file(&path) {
Ok(file) => {
return Ok(Self {
file,
path,
committed: false,
});
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"could not allocate a unique credentials cache file",
))
}
fn commit(mut self, destination: &Path, contents: &[u8]) -> Result<(), std::io::Error> {
self.file.write_all(contents)?;
self.file.sync_all()?;
std::fs::rename(&self.path, destination)?;
self.committed = true;
sync_parent_directory(destination)?;
Ok(())
}
}
impl Drop for PendingCredentials {
fn drop(&mut self) {
match self.committed {
true => {}
false => remove_pending_credentials(&self.path),
}
}
}
fn remove_pending_credentials(path: &Path) {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(
path = %path.display(),
%error,
"failed to remove temporary ACME credentials file"
),
}
}
fn open_private_file(path: &Path) -> Result<std::fs::File, std::io::Error> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path)
}
#[cfg(unix)]
fn sync_parent_directory(path: &Path) -> Result<(), std::io::Error> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::File::open(parent)?.sync_all()
}
#[cfg(not(unix))]
fn sync_parent_directory(path: &Path) -> Result<(), std::io::Error> {
tracing::debug!(
path = %path.display(),
"dns01 acme: parent directory sync is unavailable on this platform"
);
Ok(())
}
enum ProvisionStop {
Signalled,
Failed(RuntimeError),
}
impl ProvisionStop {
fn into_flow<T>(self) -> ControlFlow<(), Result<T, RuntimeError>> {
match self {
Self::Signalled => ControlFlow::Break(()),
Self::Failed(error) => ControlFlow::Continue(Err(error)),
}
}
}
impl From<RuntimeError> for ProvisionStop {
fn from(error: RuntimeError) -> Self {
Self::Failed(error)
}
}
impl From<instant_acme::Error> for ProvisionStop {
fn from(error: instant_acme::Error) -> Self {
Self::Failed(acme_err(error))
}
}
async fn guarded_step<T, E, Fut>(signals: &LifecycleSignals, step: Fut) -> Result<T, ProvisionStop>
where
Fut: Future<Output = Result<T, E>>,
ProvisionStop: From<E>,
{
match signals.guard(step).await {
ControlFlow::Break(()) => Err(ProvisionStop::Signalled),
ControlFlow::Continue(result) => result.map_err(ProvisionStop::from),
}
}
async fn run_guarded_order<P: DnsProvider>(
order: &mut instant_acme::Order,
provider: &P,
txt_records: &mut Vec<RecordId>,
signals: &LifecycleSignals,
) -> Result<(Box<str>, Box<str>), ProvisionStop> {
guarded_step(signals, create_dns_challenges(order, provider, txt_records)).await?;
guarded_step(signals, finalize_order(order)).await
}
fn cache_io<T>(operation: impl FnOnce() -> T) -> T {
crate::task::block_in_place(operation)
}
struct CachedPems {
cert: Box<[u8]>,
key: Box<[u8]>,
}
fn read_cached_pems(cert_path: &Path, key_path: &Path) -> Result<Option<CachedPems>, RuntimeError> {
match (cert_path.exists(), key_path.exists()) {
(true, true) => Ok(Some(CachedPems {
cert: std::fs::read(cert_path)?.into_boxed_slice(),
key: std::fs::read(key_path)?.into_boxed_slice(),
})),
_ => Ok(None),
}
}
pub(crate) async fn dns01_renewal_loop<P: DnsProvider + 'static>(
acme: AcmeDns01,
provider: P,
store: CertStore,
signals: LifecycleSignals,
) {
while let ControlFlow::Continue(()) = signals.tick(RENEWAL_CHECK_INTERVAL).await {
match acme.needs_renewal() {
false => continue,
true => {}
}
tracing::info!("dns01 acme: cert renewal triggered");
match acme.provision_signalled(&provider, &signals).await {
ControlFlow::Break(()) => return,
ControlFlow::Continue(Ok(new_cert)) => {
store.swap(new_cert);
tracing::info!("dns01 acme: cert renewed and swapped");
}
ControlFlow::Continue(Err(error)) => {
tracing::warn!(%error, "dns01 acme: renewal failed");
}
}
}
}
async fn create_account(
email: &Option<Box<str>>,
staging: bool,
) -> Result<(Account, AccountCredentials), RuntimeError> {
let contact_str: Option<String> = email.as_ref().map(|e| format!("mailto:{e}"));
let contact = contact_str.as_deref();
let new_account = NewAccount {
contact: contact.as_slice(),
terms_of_service_agreed: true,
only_return_existing: false,
};
let url = match staging {
true => LetsEncrypt::Staging.url(),
false => LetsEncrypt::Production.url(),
};
Account::builder()
.map_err(acme_err)?
.create(&new_account, url.into(), None)
.await
.map_err(acme_err)
}
async fn create_dns_challenges<P: DnsProvider>(
order: &mut instant_acme::Order,
provider: &P,
txt_records: &mut Vec<RecordId>,
) -> Result<(), RuntimeError> {
let mut auths = order.authorizations();
while let Some(auth_result) = auths.next().await {
let mut auth = auth_result.map_err(acme_err)?;
let mut challenge = auth
.challenge(ChallengeType::Dns01)
.ok_or_else(|| RuntimeError::Acme("no DNS-01 challenge offered".into()))?;
let fqdn = format!("_acme-challenge.{}", challenge.identifier());
let dns_value = challenge.key_authorization().dns_value();
let record_id = provider.create_txt_record(&fqdn, &dns_value).await?;
txt_records.push(record_id);
challenge.set_ready().await.map_err(acme_err)?;
}
Ok(())
}
async fn finalize_order(
order: &mut instant_acme::Order,
) -> Result<(Box<str>, Box<str>), RuntimeError> {
let retry = RetryPolicy::new().timeout(CHALLENGE_TIMEOUT);
let status = order.poll_ready(&retry).await.map_err(acme_err)?;
match status {
OrderStatus::Ready => {}
other => {
return Err(RuntimeError::Acme(
format!("order in unexpected state: {other:?}").into(),
));
}
}
let key_pem: Box<str> = order.finalize().await.map_err(acme_err)?.into();
let cert_pem: Box<str> = order
.poll_certificate(&retry)
.await
.map_err(acme_err)?
.into();
Ok((cert_pem, key_pem))
}
async fn cleanup_txt_records<P: DnsProvider>(provider: &P, record_ids: &[RecordId]) {
for id in record_ids {
if let Err(error) = provider.delete_txt_record(id).await {
tracing::warn!(record = %id, %error, "dns01 acme: TXT record cleanup failed");
}
}
}
fn read_expiry_secs(path: &Path) -> Option<i64> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "dns01 acme: cert expiry unreadable");
return None;
}
};
match contents.trim().parse::<i64>() {
Ok(secs) => Some(secs),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "dns01 acme: cert expiry malformed");
None
}
}
}
fn write_expiry(cache_dir: &Path) -> Result<(), RuntimeError> {
let expiry = now_unix_secs() + (LE_CERT_LIFETIME_DAYS * 86400);
std::fs::write(cache_dir.join("expiry"), expiry.to_string())?;
Ok(())
}
fn now_unix_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
#[cfg(unix)]
fn restrict_key_permissions(path: &Path) -> Result<(), RuntimeError> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn restrict_key_permissions(path: &Path) -> Result<(), RuntimeError> {
tracing::debug!(
path = %path.display(),
"dns01 acme: key permissions left at platform default"
);
Ok(())
}
fn acme_err(e: instant_acme::Error) -> RuntimeError {
RuntimeError::Acme(format!("{e}").into())
}