You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
355 lines
13 KiB
355 lines
13 KiB
use std::{sync, time};
|
|
|
|
use crate::domain::acme;
|
|
use crate::domain::tls::{Certificate, CertificateChain, PrivateKey};
|
|
use crate::error::unexpected::{self, Mappable};
|
|
use crate::{domain, token, util};
|
|
|
|
const LETS_ENCRYPT_URL: &str = "https://acme-v02.api.letsencrypt.org/directory";
|
|
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub enum GetHttp01ChallengeKeyError {
|
|
#[error("not found")]
|
|
NotFound,
|
|
|
|
#[error(transparent)]
|
|
Unexpected(#[from] unexpected::Error),
|
|
}
|
|
|
|
#[mockall::automock]
|
|
pub trait Manager {
|
|
fn sync_domain(
|
|
&self,
|
|
domain: domain::Name,
|
|
external_config: Option<domain::ConfigExternalDomain>,
|
|
) -> util::BoxFuture<'_, unexpected::Result<()>>;
|
|
|
|
fn get_http01_challenge_key(&self, token: &str) -> Result<String, GetHttp01ChallengeKeyError>;
|
|
|
|
fn get_certificate(
|
|
&self,
|
|
domain: &str,
|
|
) -> unexpected::Result<Option<(PrivateKey, CertificateChain)>>;
|
|
}
|
|
|
|
type BoxExternalStoreFn = Box<
|
|
dyn Fn(
|
|
&domain::ConfigExternalDomain,
|
|
) -> unexpected::Result<Box<dyn acme::store::Store + Send + Sync>>
|
|
+ Send
|
|
+ Sync,
|
|
>;
|
|
|
|
pub struct ManagerImpl {
|
|
store: Box<dyn acme::store::Store + Send + Sync>,
|
|
token_store: Box<dyn token::Store + Send + Sync>,
|
|
account: sync::Arc<acme2::Account>,
|
|
external_store_fn: BoxExternalStoreFn,
|
|
}
|
|
|
|
impl ManagerImpl {
|
|
pub async fn new<ExternalAcmeStoreFn, AcmeStore, TokenStore, AccountKeyStore>(
|
|
store: AcmeStore,
|
|
token_store: TokenStore,
|
|
account_key_store: AccountKeyStore,
|
|
external_store_fn: ExternalAcmeStoreFn,
|
|
config: &domain::ConfigACME,
|
|
) -> unexpected::Result<Self>
|
|
where
|
|
ExternalAcmeStoreFn: Fn(
|
|
&domain::ConfigExternalDomain,
|
|
) -> unexpected::Result<Box<dyn acme::store::Store + Send + Sync>>
|
|
+ Send
|
|
+ Sync
|
|
+ 'static,
|
|
AcmeStore: acme::store::Store + Send + Sync + 'static,
|
|
TokenStore: token::Store + Send + Sync + 'static,
|
|
AccountKeyStore: acme::account_key_store::Store + Send + Sync + 'static,
|
|
{
|
|
let dir = acme2::DirectoryBuilder::new(LETS_ENCRYPT_URL.to_string())
|
|
.build()
|
|
.await
|
|
.or_unexpected_while("creating acme2 directory builder")?;
|
|
|
|
let mut contact = String::from("mailto:");
|
|
contact.push_str(config.contact_email.as_str());
|
|
|
|
let mut builder = acme2::AccountBuilder::new(dir);
|
|
builder.contact(vec![contact]);
|
|
builder.terms_of_service_agreed(true);
|
|
|
|
if let Some(account_key) = account_key_store
|
|
.get()
|
|
.or_unexpected_while("fetching account key")?
|
|
{
|
|
builder.private_key(
|
|
(&account_key)
|
|
.try_into()
|
|
.or_unexpected_while("parsing private key")?,
|
|
);
|
|
}
|
|
|
|
let account = builder
|
|
.build()
|
|
.await
|
|
.or_unexpected_while("building account")?;
|
|
|
|
let account_key: PrivateKey = account
|
|
.private_key()
|
|
.as_ref()
|
|
.try_into()
|
|
.or_unexpected_while("parsing private key back out")?;
|
|
|
|
account_key_store
|
|
.set(&account_key)
|
|
.or_unexpected_while("storing account key")?;
|
|
|
|
Ok(Self {
|
|
store: Box::from(store),
|
|
token_store: Box::from(token_store),
|
|
account,
|
|
external_store_fn: Box::from(external_store_fn),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Manager for ManagerImpl {
|
|
fn sync_domain(
|
|
&self,
|
|
domain: domain::Name,
|
|
external_config: Option<domain::ConfigExternalDomain>,
|
|
) -> util::BoxFuture<'_, Result<(), unexpected::Error>> {
|
|
Box::pin(async move {
|
|
let external_store;
|
|
let store = match external_config {
|
|
None => &self.store,
|
|
Some(config) => {
|
|
external_store = (self.external_store_fn)(&config).or_unexpected()?;
|
|
&external_store
|
|
}
|
|
};
|
|
|
|
// if there's an existing cert, and its expiry (determined by the soonest value of
|
|
// not_after amongst its parts) is later than 30 days from now, then we consider it to
|
|
// be synced.
|
|
if let Ok(Some((_, certs))) = store.get_certificate(domain.as_str()) {
|
|
let thirty_days = openssl::asn1::Asn1Time::days_from_now(30)
|
|
.expect("parsed thirty days from now as Asn1Time");
|
|
|
|
let cert_with_soonest_not_after = util::try_collect(
|
|
certs
|
|
.into_iter()
|
|
.map(|cert| openssl::x509::X509::try_from(&cert)),
|
|
)
|
|
.or_unexpected_while("parsing x509 certs")?
|
|
.into_iter()
|
|
.reduce(|a, b| if a.not_after() < b.not_after() { a } else { b })
|
|
.ok_or(unexpected::Error::from(
|
|
"expected there to be more than one cert",
|
|
))?;
|
|
|
|
if thirty_days < cert_with_soonest_not_after.not_after() {
|
|
return Ok(());
|
|
}
|
|
}
|
|
|
|
log::info!("Fetching a new certificate for domain {}", domain.as_str());
|
|
let mut builder = acme2::OrderBuilder::new(self.account.clone());
|
|
builder.add_dns_identifier(domain.as_str().to_string());
|
|
let order = builder
|
|
.build()
|
|
.await
|
|
.or_unexpected_while("building order")?;
|
|
|
|
let authorizations = order
|
|
.authorizations()
|
|
.await
|
|
.or_unexpected_while("fetching authorizations")?;
|
|
|
|
for auth in authorizations {
|
|
let challenge = auth
|
|
.get_challenge("http-01")
|
|
.ok_or(unexpected::Error::from("expected http-01 challenge"))?;
|
|
|
|
let challenge_token = challenge
|
|
.token
|
|
.as_ref()
|
|
.ok_or(unexpected::Error::from("expected challenge to have token"))?;
|
|
|
|
let challenge_key = challenge
|
|
.key_authorization()
|
|
.or_unexpected_while("getting challenge key from authorization")?
|
|
.ok_or(unexpected::Error::from("expected challenge to have key"))?;
|
|
|
|
self.token_store
|
|
.set(challenge_token.clone(), challenge_key)
|
|
.or_unexpected_while("storing challenge token")?;
|
|
|
|
// At this point the manager is prepared to serve the challenge key via the
|
|
// `get_http01_challenge_key` method. It is expected that there is some http
|
|
// server, with this domain pointing at it, which is prepared to serve that
|
|
// challenge token/key under the `/.well-known/acme-challenge` path. The
|
|
// `validate()` call below will instigate the acme server to make this check, and
|
|
// block until it succeeds.
|
|
|
|
log::info!(
|
|
"Waiting for ACME challenge to be validated for domain {}",
|
|
domain.as_str(),
|
|
);
|
|
let challenge = challenge
|
|
.validate()
|
|
.await
|
|
.or_unexpected_while("initiating challenge validation")?;
|
|
|
|
// Poll the challenge every 5 seconds until it is in either the
|
|
// `valid` or `invalid` state.
|
|
let challenge_res = challenge.wait_done(time::Duration::from_secs(5), 3).await;
|
|
|
|
// no matter what the result is, clean up the challenge key
|
|
self.token_store
|
|
.del(challenge_token)
|
|
.or_unexpected_while("deleting challenge token")?;
|
|
|
|
let challenge = challenge_res.or_unexpected_while("getting challenge status")?;
|
|
|
|
if challenge.status != acme2::ChallengeStatus::Valid {
|
|
return Err(unexpected::Error::from(
|
|
format!(
|
|
"expected challenge status to be valid, instead it was {:?}",
|
|
challenge.status
|
|
)
|
|
.as_str(),
|
|
));
|
|
}
|
|
|
|
// Poll the authorization every 5 seconds until it is in either the
|
|
// `valid` or `invalid` state.
|
|
log::info!(
|
|
"Waiting for ACME authorization to be validated for domain {}",
|
|
domain.as_str(),
|
|
);
|
|
|
|
let authorization = auth
|
|
.wait_done(time::Duration::from_secs(5), 3)
|
|
.await
|
|
.or_unexpected_while("waiting for authorization status")?;
|
|
|
|
if authorization.status != acme2::AuthorizationStatus::Valid {
|
|
return Err(unexpected::Error::from(
|
|
format!(
|
|
"expected authorization status to be valid, instead it was {:?}",
|
|
authorization.status,
|
|
)
|
|
.as_str(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Poll the order every 5 seconds until it is in either the `ready` or `invalid` state.
|
|
// Ready means that it is now ready for finalization (certificate creation).
|
|
log::info!(
|
|
"Waiting for ACME order to be made ready for domain {}",
|
|
domain.as_str(),
|
|
);
|
|
|
|
let order = order
|
|
.wait_ready(time::Duration::from_secs(5), 3)
|
|
.await
|
|
.or_unexpected_while("waiting for order to be ready")?;
|
|
|
|
if order.status != acme2::OrderStatus::Ready {
|
|
return Err(unexpected::Error::from(
|
|
format!(
|
|
"expected order status to be ready, instead it was {:?}",
|
|
order.status,
|
|
)
|
|
.as_str(),
|
|
));
|
|
}
|
|
|
|
// Generate an RSA private key for the certificate.
|
|
let pkey = PrivateKey::new();
|
|
|
|
let acme2_pkey = (&pkey)
|
|
.try_into()
|
|
.or_unexpected_while("parsing new private key")?;
|
|
|
|
// Create a certificate signing request for the order, and request
|
|
// the certificate.
|
|
let order = order
|
|
.finalize(acme2::Csr::Automatic(acme2_pkey))
|
|
.await
|
|
.or_unexpected_while("finalizing order")?;
|
|
|
|
// Poll the order every 5 seconds until it is in either the
|
|
// `valid` or `invalid` state. Valid means that the certificate
|
|
// has been provisioned, and is now ready for download.
|
|
log::info!(
|
|
"Waiting for ACME order to be validated for domain {}",
|
|
domain.as_str(),
|
|
);
|
|
let order = order
|
|
.wait_done(time::Duration::from_secs(5), 3)
|
|
.await
|
|
.or_unexpected_while("waiting for order to be validated")?;
|
|
|
|
if order.status != acme2::OrderStatus::Valid {
|
|
return Err(unexpected::Error::from(
|
|
format!(
|
|
"expected order status to be valid, instead it was {:?}",
|
|
order.status,
|
|
)
|
|
.as_str(),
|
|
));
|
|
}
|
|
|
|
// Download the certificate, and panic if it doesn't exist.
|
|
log::info!("Fetching certificate for domain {}", domain.as_str());
|
|
let certs = util::try_collect(
|
|
order
|
|
.certificate()
|
|
.await
|
|
.or_unexpected_while("fetching certificate")?
|
|
.ok_or(unexpected::Error::from(
|
|
"expected the order to return a certificate",
|
|
))?
|
|
.into_iter()
|
|
.map(|cert| Certificate::try_from(cert.as_ref())),
|
|
)
|
|
.or_unexpected_while("parsing certificate")?;
|
|
|
|
if certs.len() <= 1 {
|
|
return Err(unexpected::Error::from(
|
|
format!(
|
|
"expected more than one certificate to be returned, instead got {}",
|
|
certs.len(),
|
|
)
|
|
.as_str(),
|
|
));
|
|
}
|
|
|
|
log::info!("Certificate for {} successfully retrieved", domain.as_str());
|
|
store
|
|
.set_certificate(domain.as_str(), pkey, certs.into())
|
|
.or_unexpected_while("storing new cert")?;
|
|
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn get_http01_challenge_key(&self, token: &str) -> Result<String, GetHttp01ChallengeKeyError> {
|
|
match self.token_store.get(token) {
|
|
Ok(Some(v)) => Ok(v),
|
|
Ok(None) => Err(GetHttp01ChallengeKeyError::NotFound),
|
|
Err(e) => Err(e.into()),
|
|
}
|
|
}
|
|
|
|
/// Returned vec is guaranteed to have len > 0
|
|
fn get_certificate(
|
|
&self,
|
|
domain: &str,
|
|
) -> unexpected::Result<Option<(PrivateKey, CertificateChain)>> {
|
|
self.store.get_certificate(domain)
|
|
}
|
|
}
|
|
|