1use std::sync::Arc;
34use std::time::Duration;
35
36use instant_acme::{
37 Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, NewAccount,
38 NewOrder, RetryPolicy,
39};
40use sqlx::{PgPool, Row};
41
42use crate::error::{ProxyError, ProxyResult};
43use crate::tls::cert_store::{Certificate, CertificateStore};
44
45pub const LETS_ENCRYPT_PRODUCTION: &str = "https://acme-v02.api.letsencrypt.org/directory";
47pub const LETS_ENCRYPT_STAGING: &str = "https://acme-staging-v02.api.letsencrypt.org/directory";
50
51const ORDER_TIMEOUT: Duration = Duration::from_secs(60);
57
58const FALLBACK_LIFETIME: chrono::TimeDelta = chrono::TimeDelta::days(60);
64
65pub struct AcmeIssuer {
67 directory_url: String,
68 contact: Option<String>,
69 root_pem_path: Option<std::path::PathBuf>,
74 pool: PgPool,
75 cert_store: Arc<CertificateStore>,
76}
77
78impl AcmeIssuer {
79 pub fn new(
81 directory_url: impl Into<String>,
82 contact: Option<String>,
83 pool: PgPool,
84 cert_store: Arc<CertificateStore>,
85 ) -> Self {
86 Self {
87 directory_url: directory_url.into(),
88 contact,
89 root_pem_path: None,
90 pool,
91 cert_store,
92 }
93 }
94
95 pub fn with_root_certificate(mut self, pem_path: impl Into<std::path::PathBuf>) -> Self {
100 self.root_pem_path = Some(pem_path.into());
101 self
102 }
103
104 pub fn directory_url(&self) -> &str {
106 &self.directory_url
107 }
108
109 pub async fn issue(&self, domain: &str) -> ProxyResult<Certificate> {
116 let account = self.account().await?;
117
118 let identifiers = [Identifier::Dns(domain.to_owned())];
119 let mut order = account
120 .new_order(&NewOrder::new(&identifiers))
121 .await
122 .map_err(|e| ProxyError::Acme(format!("could not place an order for {domain}: {e}")))?;
123
124 let result = self.satisfy_and_finalize(&mut order, domain).await;
125
126 if let Err(cleanup) = self.clear_challenges(domain).await {
128 tracing::warn!(%domain, error = %cleanup, "could not clear ACME challenge rows");
129 }
130
131 let (cert_pem, key_pem) = result?;
132
133 let expires_at = match crate::tls::expiry_of(cert_pem.as_bytes()) {
145 Some(expiry) => expiry,
146 None => {
147 let assumed = chrono::Utc::now() + FALLBACK_LIFETIME;
148 tracing::error!(
149 %domain,
150 assumed_expiry = %assumed,
151 "could not read the expiry from the issued chain; \
152 assuming a short lifetime so renewal is still scheduled"
153 );
154 assumed
155 }
156 };
157
158 let certificate = Certificate {
159 domain: domain.to_owned(),
160 expires_at: Some(expires_at),
161 cert_pem: cert_pem.into_bytes(),
162 key_pem: key_pem.into_bytes(),
163 };
164 self.cert_store.save(certificate.clone()).await?;
165
166 tracing::info!(
167 %domain,
168 expires_at = ?certificate.expires_at,
169 "issued an ACME certificate"
170 );
171 Ok(certificate)
172 }
173
174 async fn satisfy_and_finalize(
178 &self,
179 order: &mut instant_acme::Order,
180 domain: &str,
181 ) -> ProxyResult<(String, String)> {
182 let mut authorizations = order.authorizations();
183 while let Some(result) = authorizations.next().await {
184 let mut authz = result
185 .map_err(|e| ProxyError::Acme(format!("could not read an authorization: {e}")))?;
186
187 match authz.status {
188 AuthorizationStatus::Valid => continue,
190 AuthorizationStatus::Pending => {}
191 other => {
192 return Err(ProxyError::Acme(format!(
193 "authorization for {domain} is {other:?}, which cannot be answered"
194 )))
195 }
196 }
197
198 let mut challenge = authz.challenge(ChallengeType::Http01).ok_or_else(|| {
199 ProxyError::Acme(format!(
200 "the CA offered no http-01 challenge for {domain}; \
201 only http-01 is implemented"
202 ))
203 })?;
204
205 let token = challenge.token.clone();
208 let key_authorization = challenge.key_authorization().as_str().to_owned();
209 self.store_challenge(&token, domain, &key_authorization)
210 .await?;
211
212 challenge.set_ready().await.map_err(|e| {
213 ProxyError::Acme(format!("the CA rejected our challenge for {domain}: {e}"))
214 })?;
215 }
216
217 let retry = RetryPolicy::default().timeout(ORDER_TIMEOUT);
218
219 order.poll_ready(&retry).await.map_err(|e| {
220 ProxyError::Acme(format!(
221 "the order for {domain} did not become ready: {e}. \
222 The usual cause is that {domain} does not resolve to this proxy, \
223 so the CA could not fetch the challenge."
224 ))
225 })?;
226
227 let key_pem = order
228 .finalize()
229 .await
230 .map_err(|e| ProxyError::Acme(format!("could not finalize {domain}: {e}")))?;
231
232 let cert_pem = order
233 .poll_certificate(&retry)
234 .await
235 .map_err(|e| ProxyError::Acme(format!("no certificate came back for {domain}: {e}")))?;
236
237 Ok((cert_pem, key_pem))
238 }
239
240 async fn account(&self) -> ProxyResult<Account> {
247 if let Some(credentials) = self.load_credentials().await? {
248 return self
249 .builder()?
250 .from_credentials(credentials)
251 .await
252 .map_err(|e| {
253 ProxyError::Acme(format!("stored ACME credentials are unusable: {e}"))
254 });
255 }
256
257 let contact = self.contact.as_ref().map(|email| format!("mailto:{email}"));
258 let contact_refs: Vec<&str> = contact.iter().map(String::as_str).collect();
259
260 let (account, credentials) = self
261 .builder()?
262 .create(
263 &NewAccount {
264 contact: &contact_refs,
265 terms_of_service_agreed: true,
266 only_return_existing: false,
267 },
268 self.directory_url.clone(),
269 None,
270 )
271 .await
272 .map_err(|e| {
273 ProxyError::Acme(format!(
274 "could not register an ACME account at {}: {e}",
275 self.directory_url
276 ))
277 })?;
278
279 self.save_credentials(&credentials).await?;
280 tracing::info!(directory = %self.directory_url, "registered a new ACME account");
281 Ok(account)
282 }
283
284 fn builder(&self) -> ProxyResult<instant_acme::AccountBuilder> {
285 crate::tls::server::install_crypto_provider();
291
292 match &self.root_pem_path {
293 Some(path) => Account::builder_with_root(path).map_err(|e| {
294 ProxyError::Acme(format!(
295 "could not use {} as an ACME root: {e}",
296 path.display()
297 ))
298 }),
299 None => Account::builder()
300 .map_err(|e| ProxyError::Acme(format!("could not build an ACME client: {e}"))),
301 }
302 }
303
304 async fn load_credentials(&self) -> ProxyResult<Option<AccountCredentials>> {
305 let row =
306 sqlx::query("SELECT credentials FROM proxy_acme_accounts WHERE directory_url = $1")
307 .bind(&self.directory_url)
308 .fetch_optional(&self.pool)
309 .await?;
310
311 let Some(row) = row else { return Ok(None) };
312 let value: serde_json::Value = row.try_get("credentials")?;
313 serde_json::from_value(value)
314 .map(Some)
315 .map_err(|e| ProxyError::Acme(format!("stored ACME credentials do not parse: {e}")))
316 }
317
318 async fn save_credentials(&self, credentials: &AccountCredentials) -> ProxyResult<()> {
319 let value = serde_json::to_value(credentials)
320 .map_err(|e| ProxyError::Acme(format!("could not serialize ACME credentials: {e}")))?;
321
322 sqlx::query(
326 "INSERT INTO proxy_acme_accounts (directory_url, credentials) \
327 VALUES ($1, $2) ON CONFLICT (directory_url) DO NOTHING",
328 )
329 .bind(&self.directory_url)
330 .bind(value)
331 .execute(&self.pool)
332 .await?;
333 Ok(())
334 }
335
336 async fn store_challenge(
337 &self,
338 token: &str,
339 domain: &str,
340 key_authorization: &str,
341 ) -> ProxyResult<()> {
342 sqlx::query(
343 "INSERT INTO proxy_acme_challenges (token, domain, key_authorization) \
344 VALUES ($1, $2, $3) \
345 ON CONFLICT (token) DO UPDATE SET \
346 domain = EXCLUDED.domain, \
347 key_authorization = EXCLUDED.key_authorization, \
348 expires_at = NOW() + INTERVAL '1 hour'",
349 )
350 .bind(token)
351 .bind(domain)
352 .bind(key_authorization)
353 .execute(&self.pool)
354 .await?;
355 Ok(())
356 }
357
358 async fn clear_challenges(&self, domain: &str) -> ProxyResult<()> {
359 sqlx::query("DELETE FROM proxy_acme_challenges WHERE domain = $1 OR expires_at < NOW()")
360 .bind(domain)
361 .execute(&self.pool)
362 .await?;
363 Ok(())
364 }
365}
366
367pub struct AcmeChallengeResponse {
369 pub domain: String,
371 pub key_authorization: String,
373}
374
375pub async fn find_challenge(
380 pool: &PgPool,
381 token: &str,
382) -> ProxyResult<Option<AcmeChallengeResponse>> {
383 let row = sqlx::query(
384 "SELECT domain, key_authorization FROM proxy_acme_challenges \
385 WHERE token = $1 AND expires_at > NOW()",
386 )
387 .bind(token)
388 .fetch_optional(pool)
389 .await?;
390
391 Ok(row.map(|row| AcmeChallengeResponse {
392 domain: row.get("domain"),
393 key_authorization: row.get("key_authorization"),
394 }))
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn known_directories_are_https_urls() {
403 for url in [LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING] {
404 assert!(url.starts_with("https://"), "{url}");
405 assert!(url.ends_with("/directory"), "{url}");
406 }
407 assert_ne!(LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING);
408 }
409}