Skip to main content

cloudillo_core/
acme.rs

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