Skip to main content

cloudillo_core/
acme.rs

1//! ACME subsystem. Handles automatic certificate management using Let's Encrypt.
2
3use axum::extract::State;
4use axum::http::header::HeaderMap;
5use instant_acme::{self as acme, Account};
6use rustls::crypto::CryptoProvider;
7use rustls::sign::CertifiedKey;
8use rustls_pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer};
9use std::sync::Arc;
10use x509_parser::parse_x509_certificate;
11
12use crate::prelude::*;
13use crate::scheduler::{Task, TaskId};
14use cloudillo_types::auth_adapter;
15
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug)]
20struct X509CertData {
21	private_key_pem: Box<str>,
22	certificate_pem: Box<str>,
23	expires_at: Timestamp,
24}
25
26pub async fn init(
27	state: App,
28	acme_email: &str,
29	id_tag: &str,
30	app_domain: Option<&str>,
31) -> ClResult<()> {
32	info!("ACME init {}", acme_email);
33
34	let (account, credentials) = Account::builder()?
35		.create(
36			&acme::NewAccount {
37				contact: &[],
38				terms_of_service_agreed: true,
39				only_return_existing: false,
40			},
41			//acme::LetsEncrypt::Staging.url().to_owned(),
42			acme::LetsEncrypt::Production.url().to_owned(),
43			None,
44		)
45		.await?;
46	info!("ACME credentials {}", serde_json::to_string_pretty(&credentials)?);
47
48	// Look up the actual tenant ID instead of hardcoding to 1
49	let tn_id = state.auth_adapter.read_tn_id(id_tag).await?;
50	renew_tenant(state, &account, id_tag, tn_id.0, app_domain).await?;
51
52	Ok(())
53}
54
55pub async fn renew_tenant<'a>(
56	state: App,
57	account: &'a acme::Account,
58	id_tag: &'a str,
59	tn_id: u32,
60	app_domain: Option<&'a str>,
61) -> ClResult<()> {
62	let mut domains: Vec<String> = vec!["cl-o.".to_string() + id_tag];
63	if let Some(app_domain) = app_domain {
64		domains.push(app_domain.to_string());
65	} else {
66		info!("cloudillo app domain: {}", &id_tag);
67		domains.push(id_tag.into());
68	}
69
70	let cert = renew_domains(&state, account, domains).await?;
71	info!("ACME cert {}", &cert.expires_at);
72	state
73		.auth_adapter
74		.create_cert(&auth_adapter::CertData {
75			tn_id: TnId(tn_id),
76			id_tag: id_tag.into(),
77			domain: app_domain.unwrap_or(id_tag).into(),
78			key: cert.private_key_pem,
79			cert: cert.certificate_pem,
80			expires_at: cert.expires_at,
81		})
82		.await?;
83
84	Ok(())
85}
86
87//async fn renew_domains<'a>(state: &'a App, account: &'a acme::Account, domains: Vec<String>) -> Result<X509CertData, Box<dyn std::error::Error + 'a>> {
88async fn renew_domains<'a>(
89	state: &'a App,
90	account: &'a acme::Account,
91	domains: Vec<String>,
92) -> ClResult<X509CertData> {
93	info!("ACME {:?}", &domains);
94	let identifiers = domains
95		.iter()
96		.map(|domain| acme::Identifier::Dns(domain.clone()))
97		.collect::<Vec<_>>();
98
99	let mut order = account.new_order(&acme::NewOrder::new(identifiers.as_slice())).await?;
100
101	info!("ACME order {:#?}", order.state());
102
103	if order.state().status == acme::OrderStatus::Pending {
104		let mut authorizations = order.authorizations();
105		while let Some(result) = authorizations.next().await {
106			let mut authz = result?;
107			match authz.status {
108				acme::AuthorizationStatus::Pending => {}
109				acme::AuthorizationStatus::Valid => continue,
110				status => {
111					// Log unexpected status and continue - may be Deactivated, Expired, or Revoked
112					warn!("Unexpected ACME authorization status: {:?}", status);
113					continue;
114				}
115			}
116
117			let mut challenge = authz
118				.challenge(acme::ChallengeType::Http01)
119				.ok_or(acme::Error::Str("no challenge"))?;
120			let identifier = challenge.identifier().to_string().into_boxed_str();
121			let token: Box<str> = challenge.key_authorization().as_str().into();
122			info!("ACME challenge {} {}", identifier, token);
123			state
124				.acme_challenge_map
125				.write()
126				.map_err(|_| {
127					Error::ServiceUnavailable("failed to access ACME challenge map".into())
128				})?
129				.insert(identifier.clone(), token);
130
131			challenge.set_ready().await?;
132		}
133
134		info!("Start polling...");
135		// Create a more patient retry policy for Let's Encrypt validation
136		// Initial delay: 1s, backoff: 1.5x, timeout: 90s
137		// This gives LE plenty of time to validate multiple domains
138		let retry_policy = acme::RetryPolicy::new()
139			.initial_delay(std::time::Duration::from_secs(1))
140			.backoff(1.5)
141			.timeout(std::time::Duration::from_secs(90));
142
143		let status = order.poll_ready(&retry_policy).await?;
144
145		if status != acme::OrderStatus::Ready {
146			// Fetch authorization details to see validation errors
147			let mut authorizations = order.authorizations();
148			while let Some(result) = authorizations.next().await {
149				if let Ok(authz) = result {
150					for challenge in &authz.challenges {
151						if challenge.r#type == acme::ChallengeType::Http01 {
152							if let Some(ref err) = challenge.error {
153								warn!(
154									"ACME validation failed for {}: {}",
155									authz.identifier(),
156									err.detail.as_deref().unwrap_or("unknown error")
157								);
158							}
159						}
160					}
161				}
162			}
163			Err(acme::Error::Str("order not ready"))?;
164		}
165
166		info!("Finalizing...");
167		let private_key_pem = order.finalize().await?;
168		// Use the same patient retry policy for certificate polling
169		let cert_chain_pem = order.poll_certificate(&retry_policy).await?;
170		info!("Got cert.");
171
172		// Clean up ACME challenges
173		for domain in &domains {
174			state
175				.acme_challenge_map
176				.write()
177				.map_err(|_| {
178					Error::ServiceUnavailable("failed to access ACME challenge map".into())
179				})?
180				.remove(domain.as_str());
181		}
182
183		let pem = &pem::parse(&cert_chain_pem)?;
184		let cert_der = pem.contents();
185		let (_, parsed_cert) = parse_x509_certificate(cert_der)?;
186		let not_after = parsed_cert.validity().not_after;
187
188		let certified_key = Arc::new(CertifiedKey::from_der(
189			CertificateDer::pem_slice_iter(cert_chain_pem.as_bytes())
190				.filter_map(Result::ok)
191				.collect(),
192			PrivateKeyDer::from_pem_slice(private_key_pem.as_bytes())?,
193			CryptoProvider::get_default().ok_or(acme::Error::Str("no crypto provider"))?,
194		)?);
195		for domain in &domains {
196			state
197				.certs
198				.write()
199				.map_err(|_| Error::ServiceUnavailable("failed to access cert cache".into()))?
200				.insert(domain.clone().into_boxed_str(), certified_key.clone());
201		}
202
203		let cert_data = X509CertData {
204			private_key_pem: private_key_pem.clone().into_boxed_str(),
205			certificate_pem: cert_chain_pem.clone().into_boxed_str(),
206			expires_at: Timestamp(not_after.timestamp()),
207		};
208
209		Ok(cert_data)
210	} else {
211		Err(Error::ConfigError("ACME initialization failed".into()))
212	}
213}
214
215pub async fn get_acme_challenge(
216	State(state): State<App>,
217	headers: HeaderMap,
218) -> ClResult<Box<str>> {
219	let domain = headers
220		.get("host")
221		.ok_or(Error::ValidationError("missing host header".into()))?
222		.to_str()?;
223	info!("ACME challenge for domain {:?}", domain);
224
225	if let Some(token) = state
226		.acme_challenge_map
227		.read()
228		.map_err(|_| Error::ServiceUnavailable("failed to access ACME challenge map".into()))?
229		.get(domain)
230	{
231		println!("    -> {:?}", &token);
232		Ok(token.clone())
233	} else {
234		println!("    -> not found");
235		Err(Error::PermissionDenied)
236	}
237}
238
239/// Renew the TLS certificate for a single proxy site via ACME.
240///
241/// Creates an ACME account, generates the certificate, stores it in the auth adapter,
242/// and invalidates the cert cache. This is called inline from proxy site creation
243/// and manual renewal endpoints, as well as from the periodic `CertRenewalTask`.
244pub async fn renew_proxy_site_cert(app: &App, site_id: i64, domain: &str) -> ClResult<()> {
245	let (account, _credentials) = Account::builder()?
246		.create(
247			&acme::NewAccount {
248				contact: &[],
249				terms_of_service_agreed: true,
250				only_return_existing: false,
251			},
252			acme::LetsEncrypt::Production.url().to_owned(),
253			None,
254		)
255		.await?;
256
257	let domains = vec![domain.to_string()];
258	let cert = renew_domains(app, &account, domains).await?;
259
260	app.auth_adapter
261		.update_proxy_site_cert(
262			site_id,
263			&cert.certificate_pem,
264			&cert.private_key_pem,
265			cert.expires_at,
266		)
267		.await?;
268
269	// Note: renew_domains() already inserts the fresh cert into app.certs cache,
270	// so no cache invalidation needed here.
271
272	info!(domain = %domain, "Proxy site certificate renewed successfully");
273	Ok(())
274}
275
276// Certificate Renewal Task
277// ========================
278
279/// Certificate renewal task
280///
281/// Checks all tenants for missing or expiring certificates and renews them.
282/// Scheduled to run hourly via cron: "0 * * * *"
283#[derive(Clone, Debug, Serialize, Deserialize)]
284pub struct CertRenewalTask {
285	/// Number of days before expiration to trigger renewal (default: 30)
286	pub renewal_days: u32,
287	/// ACME email for account creation
288	pub acme_email: String,
289}
290
291impl CertRenewalTask {
292	/// Create new certificate renewal task
293	pub fn new(acme_email: String, renewal_days: u32) -> Self {
294		Self { renewal_days, acme_email }
295	}
296}
297
298#[async_trait]
299impl Task<App> for CertRenewalTask {
300	fn kind() -> &'static str {
301		"acme.cert_renewal"
302	}
303
304	fn kind_of(&self) -> &'static str {
305		Self::kind()
306	}
307
308	fn build(_id: TaskId, context: &str) -> ClResult<Arc<dyn Task<App>>> {
309		let task: CertRenewalTask = serde_json::from_str(context).map_err(|e| {
310			Error::ValidationError(format!("Failed to deserialize cert renewal task: {}", e))
311		})?;
312		Ok(Arc::new(task))
313	}
314
315	fn serialize(&self) -> String {
316		serde_json::to_string(self)
317			.unwrap_or_else(|_| format!("acme.cert_renewal:{}", self.renewal_days))
318	}
319
320	async fn run(&self, app: &App) -> ClResult<()> {
321		info!("Running certificate renewal check (renewal threshold: {} days)", self.renewal_days);
322
323		// Get list of tenants needing renewal
324		let tenants = app.auth_adapter.list_tenants_needing_cert_renewal(self.renewal_days).await?;
325
326		if tenants.is_empty() {
327			info!("All tenant certificates are valid");
328		}
329
330		if !tenants.is_empty() {
331			info!("Found {} tenant(s) needing certificate renewal", tenants.len());
332
333			// Renew certificates for each tenant
334			for (tn_id, id_tag) in tenants {
335				info!("Renewing certificate for tenant: {} (tn_id={})", id_tag, tn_id.0);
336
337				// Determine app_domain (only base tenant gets custom domain)
338				let app_domain = if tn_id.0 == 1 {
339					// For base tenant, check if there's a custom domain configured
340					// TODO: Get this from app configuration/settings
341					None
342				} else {
343					None
344				};
345
346				// Perform ACME renewal
347				match init(app.clone(), &self.acme_email, &id_tag, app_domain).await {
348					Ok(()) => {
349						info!(tenant = %id_tag, "Certificate renewed successfully");
350					}
351					Err(e) => {
352						error!(tenant = %id_tag, error = %e, "Failed to renew certificate");
353						// Continue with other tenants even if one fails
354					}
355				}
356			}
357		}
358
359		// Renew proxy site certificates
360		let proxy_sites = app
361			.auth_adapter
362			.list_proxy_sites_needing_cert_renewal(self.renewal_days)
363			.await?;
364
365		if !proxy_sites.is_empty() {
366			info!("Found {} proxy site(s) needing certificate renewal", proxy_sites.len());
367
368			for site in proxy_sites {
369				info!(
370					"Renewing certificate for proxy site: {} (site_id={})",
371					site.domain, site.site_id
372				);
373
374				if let Err(e) = renew_proxy_site_cert(app, site.site_id, &site.domain).await {
375					error!(
376						domain = %site.domain,
377						error = %e,
378						"Failed to renew proxy site certificate"
379					);
380				}
381			}
382		}
383
384		info!("Certificate renewal check completed");
385		Ok(())
386	}
387}
388
389/// Register ACME-related tasks with the scheduler
390///
391/// Must be called during app initialization before the scheduler starts loading tasks
392pub fn register_tasks(app: &App) -> ClResult<()> {
393	app.scheduler.register::<CertRenewalTask>()?;
394	Ok(())
395}
396
397// vim: ts=4