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