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::{CertificateDer, PrivateKeyDer, pem::PemObject};
12use std::sync::Arc;
13use x509_parser::parse_x509_certificate;
14
15use crate::dns::{DnsResolver, create_recursive_resolver, validate_domain_address};
16use crate::prelude::*;
17use crate::scheduler::{Task, TaskId};
18use crate::{ScheduleEmailFn, ScheduleEmailParams};
19use cloudillo_types::auth_adapter::{self, TenantCertRenewalRow};
20use cloudillo_types::validation::id_tag_to_ascii_lossy;
21
22use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24
25#[derive(Debug)]
26struct X509CertData {
27	private_key_pem: Box<str>,
28	certificate_pem: Box<str>,
29	expires_at: Timestamp,
30}
31
32/// Vars-table key for the persisted ACME account credentials. Stored under
33/// `TnId(0)` (global), matching the convention used for other server-wide
34/// secrets like `0:jwt_secret`.
35const ACME_ACCOUNT_VAR: &str = "acme_account";
36
37/// Load the persisted ACME account, or create a new one and persist its
38/// credentials on first use. Without persistence we'd hit Let's Encrypt's
39/// per-IP account-creation rate limit on every renewal cycle and leak the
40/// account key into the log on every call.
41async fn get_or_create_acme_account(state: &App, acme_email: &str) -> ClResult<Account> {
42	match state.auth_adapter.read_var(TnId(0), ACME_ACCOUNT_VAR).await {
43		Ok(json) => {
44			let credentials: acme::AccountCredentials = serde_json::from_str(&json)
45				.map_err(|_| Error::Internal("corrupt ACME credentials in vars".into()))?;
46			Ok(Account::builder()?.from_credentials(credentials).await?)
47		}
48		Err(Error::NotFound) => {
49			info!("Creating new ACME account for {}", acme_email);
50			let contact = format!("mailto:{}", acme_email);
51			let (account, credentials) = Account::builder()?
52				.create(
53					&acme::NewAccount {
54						contact: &[&contact],
55						terms_of_service_agreed: true,
56						only_return_existing: false,
57					},
58					acme::LetsEncrypt::Production.url().to_owned(),
59					None,
60				)
61				.await?;
62			let json = serde_json::to_string(&credentials)?;
63			state.auth_adapter.update_var(TnId(0), ACME_ACCOUNT_VAR, &json).await?;
64			Ok(account)
65		}
66		Err(e) => Err(e),
67	}
68}
69
70pub async fn init(
71	state: App,
72	acme_email: &str,
73	id_tag: &str,
74	app_domain: Option<&str>,
75) -> ClResult<()> {
76	info!("ACME init {}", acme_email);
77	let account = get_or_create_acme_account(&state, acme_email).await?;
78
79	// Look up the actual tenant ID instead of hardcoding to 1
80	let tn_id = state.auth_adapter.read_tn_id(id_tag).await?;
81	renew_tenant(state, &account, id_tag, tn_id.0, app_domain).await?;
82
83	Ok(())
84}
85
86pub async fn renew_tenant<'a>(
87	state: App,
88	account: &'a acme::Account,
89	id_tag: &'a str,
90	tn_id: u32,
91	app_domain: Option<&'a str>,
92) -> ClResult<()> {
93	let domains = build_domains_for_tenant(id_tag, app_domain);
94	if app_domain.is_none() {
95		info!("cloudillo app domain: {}", &id_tag);
96	}
97
98	let cert = renew_domains(&state, account, domains).await?;
99	info!("ACME cert {}", &cert.expires_at);
100	state
101		.auth_adapter
102		.create_cert(&auth_adapter::CertData {
103			tn_id: TnId(tn_id),
104			id_tag: id_tag.into(),
105			domain: app_domain.unwrap_or(id_tag).into(),
106			key: cert.private_key_pem,
107			cert: cert.certificate_pem,
108			expires_at: cert.expires_at,
109			last_renewal_attempt_at: None,
110			last_renewal_error: None,
111			failure_count: 0,
112			notified_at: None,
113		})
114		.await?;
115
116	Ok(())
117}
118
119async fn renew_domains<'a>(
120	state: &'a App,
121	account: &'a acme::Account,
122	domains: Vec<String>,
123) -> ClResult<X509CertData> {
124	// Track every identifier we actually inserted into acme_challenge_map so
125	// we can remove the exact same keys on cleanup. The ACME server is free
126	// to normalize identifiers (case, trailing dots) and using the input
127	// `domains` list for removal could miss them.
128	let mut inserted_identifiers: Vec<Box<str>> = Vec::new();
129	let result = renew_domains_inner(state, account, &domains, &mut inserted_identifiers).await;
130
131	// Always clean up challenges, on both success and failure paths.
132	if let Ok(mut map) = state.acme_challenge_map.write() {
133		for ident in &inserted_identifiers {
134			map.remove(ident.as_ref());
135		}
136	} else {
137		warn!("ACME: failed to access challenge map for cleanup");
138	}
139
140	result
141}
142
143async fn renew_domains_inner<'a>(
144	state: &'a App,
145	account: &'a acme::Account,
146	domains: &'a [String],
147	inserted_identifiers: &'a mut Vec<Box<str>>,
148) -> ClResult<X509CertData> {
149	info!("ACME {:?}", domains);
150	let identifiers = domains
151		.iter()
152		.map(|domain| acme::Identifier::Dns(domain.clone()))
153		.collect::<Vec<_>>();
154
155	let mut order = account.new_order(&acme::NewOrder::new(identifiers.as_slice())).await?;
156
157	debug!("ACME order {:#?}", order.state());
158
159	let initial_status = order.state().status;
160	// `Pending` is the normal first-time path. `Ready` can happen when LE has
161	// already validated authorizations on a recent retry — finalize directly.
162	// Anything else (Valid/Invalid/Processing) is unexpected and should fail.
163	match initial_status {
164		acme::OrderStatus::Pending => {
165			let mut authorizations = order.authorizations();
166			while let Some(result) = authorizations.next().await {
167				let mut authz = result?;
168				match authz.status {
169					acme::AuthorizationStatus::Pending => {}
170					acme::AuthorizationStatus::Valid => continue,
171					status => {
172						// Log unexpected status and continue - may be Deactivated, Expired, or Revoked
173						warn!("Unexpected ACME authorization status: {:?}", status);
174						continue;
175					}
176				}
177
178				let mut challenge = authz
179					.challenge(acme::ChallengeType::Http01)
180					.ok_or(acme::Error::Str("no challenge"))?;
181				let identifier: Box<str> = challenge.identifier().to_string().into_boxed_str();
182				let token: Box<str> = challenge.key_authorization().as_str().into();
183				debug!("ACME challenge {} {}", identifier, token);
184				state
185					.acme_challenge_map
186					.write()
187					.map_err(|_| {
188						Error::ServiceUnavailable("failed to access ACME challenge map".into())
189					})?
190					.insert(identifier.clone(), token);
191				inserted_identifiers.push(identifier);
192
193				challenge.set_ready().await?;
194			}
195
196			info!("Start polling...");
197			// Create a more patient retry policy for Let's Encrypt validation
198			// Initial delay: 1s, backoff: 1.5x, timeout: 90s
199			// This gives LE plenty of time to validate multiple domains
200			let retry_policy = acme::RetryPolicy::new()
201				.initial_delay(std::time::Duration::from_secs(1))
202				.backoff(1.5)
203				.timeout(std::time::Duration::from_secs(90));
204
205			let status = order.poll_ready(&retry_policy).await?;
206
207			if status != acme::OrderStatus::Ready {
208				// Fetch authorization details to see validation errors
209				let mut authorizations = order.authorizations();
210				while let Some(result) = authorizations.next().await {
211					if let Ok(authz) = result {
212						for challenge in &authz.challenges {
213							if challenge.r#type == acme::ChallengeType::Http01
214								&& let Some(ref err) = challenge.error
215							{
216								warn!(
217									"ACME validation failed for {}: {}",
218									authz.identifier(),
219									err.detail.as_deref().unwrap_or("unknown error")
220								);
221							}
222						}
223					}
224				}
225				Err(acme::Error::Str("order not ready"))?;
226			}
227		}
228		acme::OrderStatus::Ready => {
229			info!("ACME order already Ready - skipping authorization phase");
230		}
231		other => {
232			warn!("Unexpected ACME order status on creation: {:?}", other);
233			return Err(Error::ConfigError("ACME initialization failed".into()));
234		}
235	}
236
237	let retry_policy = acme::RetryPolicy::new()
238		.initial_delay(std::time::Duration::from_secs(1))
239		.backoff(1.5)
240		.timeout(std::time::Duration::from_secs(90));
241
242	info!("Finalizing...");
243	let private_key_pem = order.finalize().await?;
244	let cert_chain_pem = order.poll_certificate(&retry_policy).await?;
245	info!("Got cert.");
246
247	let pem = &pem::parse(&cert_chain_pem)?;
248	let cert_der = pem.contents();
249	let (_, parsed_cert) = parse_x509_certificate(cert_der)?;
250	let not_after = parsed_cert.validity().not_after;
251
252	let certified_key = Arc::new(CertifiedKey::from_der(
253		CertificateDer::pem_slice_iter(cert_chain_pem.as_bytes())
254			.filter_map(Result::ok)
255			.collect(),
256		PrivateKeyDer::from_pem_slice(private_key_pem.as_bytes())?,
257		CryptoProvider::get_default().ok_or(acme::Error::Str("no crypto provider"))?,
258	)?);
259	for domain in domains {
260		state
261			.certs
262			.write()
263			.map_err(|_| Error::ServiceUnavailable("failed to access cert cache".into()))?
264			.insert(domain.clone().into_boxed_str(), certified_key.clone());
265	}
266
267	let cert_data = X509CertData {
268		private_key_pem: private_key_pem.into_boxed_str(),
269		certificate_pem: cert_chain_pem.into_boxed_str(),
270		expires_at: Timestamp(not_after.timestamp()),
271	};
272
273	Ok(cert_data)
274}
275
276pub async fn get_acme_challenge(
277	State(state): State<App>,
278	headers: HeaderMap,
279) -> ClResult<Box<str>> {
280	let domain = headers
281		.get("host")
282		.ok_or(Error::ValidationError("missing host header".into()))?
283		.to_str()?;
284	info!("ACME challenge for domain {:?}", domain);
285
286	if let Some(token) = state
287		.acme_challenge_map
288		.read()
289		.map_err(|_| Error::ServiceUnavailable("failed to access ACME challenge map".into()))?
290		.get(domain)
291	{
292		debug!("ACME challenge served for {}", domain);
293		Ok(token.clone())
294	} else {
295		debug!("ACME challenge not found for {}", domain);
296		Err(Error::PermissionDenied)
297	}
298}
299
300/// Renew the TLS certificate for a single proxy site via ACME.
301///
302/// Loads the persisted ACME account (creating it on first use), generates the
303/// certificate, stores it in the auth adapter, and invalidates the cert cache.
304/// Called inline from proxy site creation and manual renewal endpoints, as
305/// well as from the periodic `CertRenewalTask`.
306pub async fn renew_proxy_site_cert(
307	app: &App,
308	acme_email: &str,
309	site_id: i64,
310	domain: &str,
311) -> ClResult<()> {
312	let account = get_or_create_acme_account(app, acme_email).await?;
313
314	let domains = vec![domain.to_string()];
315	let cert = renew_domains(app, &account, domains).await?;
316
317	app.auth_adapter
318		.update_proxy_site_cert(
319			site_id,
320			&cert.certificate_pem,
321			&cert.private_key_pem,
322			cert.expires_at,
323		)
324		.await?;
325
326	// Note: renew_domains() already inserts the fresh cert into app.certs cache,
327	// so no cache invalidation needed here.
328
329	info!(domain = %domain, "Proxy site certificate renewed successfully");
330	Ok(())
331}
332
333// Certificate Renewal Task
334// ========================
335
336/// Certificate renewal task
337///
338/// Checks all tenants for missing or expiring certificates and renews them.
339/// Scheduled to run hourly via cron: "0 * * * *"
340#[derive(Clone, Debug, Serialize, Deserialize)]
341pub struct CertRenewalTask {
342	/// Number of days before expiration to trigger renewal (default: 30)
343	pub renewal_days: u32,
344	/// ACME email for account creation
345	pub acme_email: String,
346}
347
348impl CertRenewalTask {
349	/// Create new certificate renewal task
350	pub fn new(acme_email: String, renewal_days: u32) -> Self {
351		Self { renewal_days, acme_email }
352	}
353}
354
355#[async_trait]
356impl Task<App> for CertRenewalTask {
357	fn kind() -> &'static str {
358		"acme.cert_renewal"
359	}
360
361	fn kind_of(&self) -> &'static str {
362		Self::kind()
363	}
364
365	fn build(_id: TaskId, context: &str) -> ClResult<Arc<dyn Task<App>>> {
366		let task: CertRenewalTask = serde_json::from_str(context).map_err(|e| {
367			Error::ValidationError(format!("Failed to deserialize cert renewal task: {}", e))
368		})?;
369		Ok(Arc::new(task))
370	}
371
372	fn serialize(&self) -> String {
373		// Cannot fail: only String and u32 fields, no custom Serialize impl.
374		// Fallback to "null" so build() fails loudly rather than creating a
375		// corrupt task with default values.
376		serde_json::to_string(self).unwrap_or_else(|_| "null".to_string())
377	}
378
379	async fn run(&self, app: &App) -> ClResult<()> {
380		info!("Running certificate renewal check (renewal threshold: {} days)", self.renewal_days);
381
382		let tenants = app.auth_adapter.list_tenants_needing_cert_renewal(self.renewal_days).await?;
383		let proxy_sites = app
384			.auth_adapter
385			.list_proxy_sites_needing_cert_renewal(self.renewal_days)
386			.await?;
387
388		if tenants.is_empty() && proxy_sites.is_empty() {
389			info!("All certificates are valid");
390			return Ok(());
391		}
392
393		// Single resolver for the whole batch — same pattern as register.rs
394		let resolver = match create_recursive_resolver() {
395			Ok(r) => r,
396			Err(e) => {
397				error!(error = %e, "Cannot create DNS resolver; skipping renewal run");
398				return Ok(());
399			}
400		};
401
402		if !tenants.is_empty() {
403			info!("Found {} tenant(s) needing certificate renewal", tenants.len());
404			for row in tenants {
405				let app_domain: Option<&str> = None; // No custom domain support yet
406				let domains = build_domains_for_tenant(&row.id_tag, app_domain);
407
408				match check_domains_dns(&domains, &app.opts.local_address, &resolver).await {
409					Ok(()) => {}
410					Err(PreCheckError::Definitive(reason)) => {
411						warn!(
412							tn_id = %row.tn_id.0,
413							id_tag = %row.id_tag,
414							reason = %reason,
415							"Skipping ACME renewal: DNS pre-check failed"
416						);
417						handle_renewal_failure(app, &row, &reason).await;
418						continue;
419					}
420					Err(PreCheckError::Transient(reason)) => {
421						warn!(
422							tn_id = %row.tn_id.0,
423							id_tag = %row.id_tag,
424							reason = %reason,
425							"Skipping ACME renewal this run: transient DNS resolver error \
426							 (not counted as failure)"
427						);
428						continue;
429					}
430				}
431
432				info!("Renewing certificate for tenant: {} (tn_id={})", row.id_tag, row.tn_id.0);
433				match init(app.clone(), &self.acme_email, &row.id_tag, app_domain).await {
434					Ok(()) => {
435						info!(tn_id = %row.tn_id.0, id_tag = %row.id_tag,
436							"Certificate renewed successfully");
437						handle_renewal_success(app, &row, false).await;
438					}
439					Err(e) => {
440						let reason = format!("acme: {}", e);
441						error!(tn_id = %row.tn_id.0, id_tag = %row.id_tag, error = %reason,
442							"Failed to renew certificate");
443						handle_renewal_failure(app, &row, &reason).await;
444					}
445				}
446			}
447		}
448
449		if !proxy_sites.is_empty() {
450			info!("Found {} proxy site(s) needing certificate renewal", proxy_sites.len());
451
452			for site in proxy_sites {
453				let domains: Vec<String> = vec![site.domain.to_string()];
454				match check_domains_dns(&domains, &app.opts.local_address, &resolver).await {
455					Ok(()) => {}
456					Err(PreCheckError::Definitive(reason)) => {
457						warn!(
458							domain = %site.domain,
459							reason = %reason,
460							"Skipping ACME renewal for proxy site: DNS pre-check failed"
461						);
462						continue;
463					}
464					Err(PreCheckError::Transient(reason)) => {
465						warn!(
466							domain = %site.domain,
467							reason = %reason,
468							"Skipping ACME renewal for proxy site this run: transient DNS \
469							 resolver error"
470						);
471						continue;
472					}
473				}
474
475				info!(
476					"Renewing certificate for proxy site: {} (site_id={})",
477					site.domain, site.site_id
478				);
479
480				if let Err(e) =
481					renew_proxy_site_cert(app, &self.acme_email, site.site_id, &site.domain).await
482				{
483					error!(
484						domain = %site.domain,
485						error = %e,
486						"Failed to renew proxy site certificate"
487					);
488				}
489			}
490		}
491
492		info!("Certificate renewal check completed");
493		Ok(())
494	}
495}
496
497/// One-shot retry of `acme::init` for a single tenant, scheduled by bootstrap
498/// when the at-registration ACME attempt fails. Coordinated with the daily
499/// `CertRenewalTask` only by the in-task `read_cert_by_tn_id` short-circuit:
500/// each retry checks first whether a cert was installed since it was queued,
501/// and if so exits without contacting the ACME directory.
502///
503/// Three of these are typically queued at 2 / 5 / 15-minute delays. They
504/// survive process restart (unlike the previous `tokio::spawn` approach), and
505/// the per-key dedup in the scheduler stops repeated bootstrap-failure events
506/// from stacking duplicate retries on top of each other.
507#[derive(Clone, Debug, Serialize, Deserialize)]
508pub struct AcmeEarlyRetryTask {
509	pub tn_id: TnId,
510	pub acme_email: String,
511	pub id_tag: String,
512	pub app_domain: Option<String>,
513}
514
515#[async_trait]
516impl Task<App> for AcmeEarlyRetryTask {
517	fn kind() -> &'static str {
518		"acme.early_retry"
519	}
520
521	fn kind_of(&self) -> &'static str {
522		Self::kind()
523	}
524
525	fn build(_id: TaskId, context: &str) -> ClResult<Arc<dyn Task<App>>> {
526		let task: AcmeEarlyRetryTask = serde_json::from_str(context).map_err(|e| {
527			Error::ValidationError(format!("Failed to deserialize early retry task: {}", e))
528		})?;
529		Ok(Arc::new(task))
530	}
531
532	fn serialize(&self) -> String {
533		// Same as CertRenewalTask::serialize — no fallible field types.
534		serde_json::to_string(self).unwrap_or_else(|_| "null".to_string())
535	}
536
537	async fn run(&self, app: &App) -> ClResult<()> {
538		// `read_cert_by_tn_id` filters `cert IS NOT NULL AND key IS NOT NULL`,
539		// so Ok(_) ⇒ cert installed (by an earlier retry or the daily renewal
540		// task) and we should stop. Err(NotFound) ⇒ proceed.
541		if app.auth_adapter.read_cert_by_tn_id(self.tn_id).await.is_ok() {
542			info!(id_tag = %self.id_tag,
543				"ACME early retry: cert already present, skipping");
544			return Ok(());
545		}
546		info!(id_tag = %self.id_tag, "ACME early retry attempt");
547		match init(app.clone(), &self.acme_email, &self.id_tag, self.app_domain.as_deref()).await {
548			Ok(()) => {
549				info!(id_tag = %self.id_tag, "ACME early retry succeeded");
550				let row = TenantCertRenewalRow {
551					tn_id: self.tn_id,
552					id_tag: self.id_tag.clone().into(),
553					expires_at: None,
554					failure_count: 0,
555					last_renewal_error: None,
556					notified_at: None,
557				};
558				handle_renewal_success(app, &row, true).await;
559				Ok(())
560			}
561			Err(e) => {
562				warn!(error = %e, id_tag = %self.id_tag, "ACME early retry failed");
563				// Surface the error so the scheduler records the failure, but
564				// the other queued retries (separate tasks) still fire.
565				Err(e)
566			}
567		}
568	}
569}
570
571/// Register ACME-related tasks with the scheduler
572///
573/// Must be called during app initialization before the scheduler starts loading tasks
574pub fn register_tasks(app: &App) -> ClResult<()> {
575	app.scheduler.register::<CertRenewalTask>()?;
576	app.scheduler.register::<AcmeEarlyRetryTask>()?;
577	Ok(())
578}
579
580// ============================================================================
581// DNS pre-check + renewal-failure tracking helpers
582// ============================================================================
583
584const RENEWAL_NOTIFY_LONG_INTERVAL_SECS: i64 = 7 * 86400;
585const RENEWAL_NOTIFY_SHORT_INTERVAL_SECS: i64 = 86400;
586
587/// Build the list of domains a tenant cert needs to cover — one definition shared by
588/// `renew_tenant` and the DNS pre-check.
589///
590/// id_tags are stored as UTS #46 U-labels, but ACME identifiers, DNS and TLS SNI all
591/// speak punycode, so both entries derived from the id_tag go out as A-labels.
592/// `app_domain` is an operator-configured hostname, not an id_tag, and passes through
593/// verbatim.
594fn build_domains_for_tenant(id_tag: &str, app_domain: Option<&str>) -> Vec<String> {
595	let ascii = id_tag_to_ascii_lossy(id_tag);
596	vec![
597		format!("cl-o.{}", ascii),
598		app_domain.map_or_else(|| ascii.into_owned(), ToString::to_string),
599	]
600}
601
602/// Outcome of a DNS pre-check. Definitive failures (`"nodns"`, `"address"`)
603/// are deterministic — the tenant's DNS is genuinely misconfigured — so they
604/// escalate to suspension/notification. Transient failures (resolver network
605/// errors, timeouts) are not the tenant's fault; the renewal is skipped this
606/// run but failure_count / suspension state is left untouched so a flaky
607/// resolver around expiry can't push a healthy tenant into Suspended.
608enum PreCheckError {
609	Definitive(String),
610	Transient(String),
611}
612
613/// DNS pre-check for every domain in the list. Returns the error code
614/// (`"nodns"` or `"address"`, matching `register.rs` conventions) on the
615/// first definitive failure, or a transient error wrapping the underlying
616/// resolver error. If `local_address` is empty (e.g., local dev), the check
617/// is skipped — same as `register.rs` does.
618async fn check_domains_dns(
619	domains: &[String],
620	local_address: &[Box<str>],
621	resolver: &DnsResolver,
622) -> Result<(), PreCheckError> {
623	if local_address.is_empty() {
624		return Ok(());
625	}
626	for domain in domains {
627		match validate_domain_address(domain, local_address, resolver).await {
628			Ok(_) => {}
629			Err(Error::ValidationError(code)) => return Err(PreCheckError::Definitive(code)),
630			Err(e) => return Err(PreCheckError::Transient(format!("{}", e))),
631		}
632	}
633	Ok(())
634}
635
636pub async fn handle_renewal_success(
637	app: &App,
638	row: &TenantCertRenewalRow,
639	is_first_issuance: bool,
640) {
641	if let Err(e) = app.auth_adapter.record_cert_renewal_success(row.tn_id).await {
642		warn!(tn_id = %row.tn_id.0, id_tag = %row.id_tag, error = %e,
643			"Failed to record renewal success");
644	}
645	// Only flip status back to active when this row was previously suspended
646	// (i.e. its prior cert was past expiry). Calling `update_tenant_status('A')`
647	// unconditionally would bump `tenants.updated_at` on every nightly run for
648	// every healthy tenant.
649	let is_currently_expired = row.expires_at.is_some_and(|t| t.0 < Timestamp::now().0);
650	if is_currently_expired {
651		if let Err(e) = app.auth_adapter.update_tenant_status(row.tn_id, 'A').await {
652			warn!(tn_id = %row.tn_id.0, id_tag = %row.id_tag, error = %e,
653				"Failed to clear suspended status after renewal");
654		} else {
655			info!(tn_id = %row.tn_id.0, id_tag = %row.id_tag,
656				"Tenant un-suspended after successful cert renewal");
657		}
658	}
659
660	// First-issuance hook: only fire when the caller passed
661	// `is_first_issuance = true` (currently only the bootstrap synthetic-row
662	// paths). The daily renewal task passes `false` here, so a row whose
663	// `expires_at` is somehow NULL for a non-first reason will not re-fire
664	// the hook.
665	if is_first_issuance
666		&& let Ok(hook) = app.ext::<crate::OnFirstCertIssuedFn>()
667		&& let Err(e) = hook(app, row.tn_id, &row.id_tag).await
668	{
669		warn!(tn_id = %row.tn_id.0, id_tag = %row.id_tag, error = %e,
670			"on_first_cert_issued hook failed");
671	}
672}
673
674async fn handle_renewal_failure(app: &App, row: &TenantCertRenewalRow, reason: &str) {
675	// Always record the failure so we have a counter, even on the
676	// initial-bootstrap path (no cert yet). The adapter upserts the row.
677	if let Err(e) = app.auth_adapter.record_cert_renewal_failure(row.tn_id, reason).await {
678		warn!(tn_id = %row.tn_id.0, id_tag = %row.id_tag, error = %e,
679			"Failed to record renewal failure");
680	}
681
682	let now = Timestamp::now().0;
683
684	let (days_until_expiry, already_expired) = match row.expires_at {
685		Some(expires_at) => {
686			let days = (expires_at.0 - now) / 86400;
687			(days, days <= 0)
688		}
689		// No cert yet — treat as already-expired for suspension/notify cadence.
690		None => (0, true),
691	};
692
693	// Suspend the tenant once the cert is past expiry (or absent). Flipping an
694	// already-suspended tenant to 'S' is a no-op; we never downgrade here.
695	if already_expired && let Err(e) = app.auth_adapter.update_tenant_status(row.tn_id, 'S').await {
696		warn!(tn_id = %row.tn_id.0, id_tag = %row.id_tag, error = %e,
697			"Failed to mark tenant suspended");
698	}
699
700	let should_notify = should_notify(row, now, days_until_expiry);
701	if !should_notify {
702		return;
703	}
704
705	let expires_at = row.expires_at.unwrap_or(Timestamp(now));
706	if let Err(e) = schedule_renewal_failure_email(
707		app,
708		row,
709		reason,
710		expires_at,
711		days_until_expiry,
712		already_expired,
713	)
714	.await
715	{
716		warn!(tn_id = %row.tn_id.0, id_tag = %row.id_tag, error = %e,
717			"Failed to schedule renewal-failure email");
718		return;
719	}
720
721	if let Err(e) = app.auth_adapter.record_cert_renewal_notification(row.tn_id).await {
722		warn!(tn_id = %row.tn_id.0, id_tag = %row.id_tag, error = %e,
723			"Failed to stamp notified_at");
724	}
725}
726
727fn should_notify(row: &TenantCertRenewalRow, now: i64, days_until_expiry: i64) -> bool {
728	// First failure (no notification recorded yet): always notify.
729	let Some(last) = row.notified_at else {
730		return true;
731	};
732	let interval = if days_until_expiry <= 7 {
733		RENEWAL_NOTIFY_SHORT_INTERVAL_SECS
734	} else {
735		RENEWAL_NOTIFY_LONG_INTERVAL_SECS
736	};
737	now - last.0 >= interval
738}
739
740async fn schedule_renewal_failure_email(
741	app: &App,
742	row: &TenantCertRenewalRow,
743	reason: &str,
744	expires_at: Timestamp,
745	days_until_expiry: i64,
746	suspended: bool,
747) -> ClResult<()> {
748	let schedule_email = app.ext::<ScheduleEmailFn>()?;
749
750	// Tenant email lives on AuthProfile.
751	let profile = app.auth_adapter.read_tenant(&row.id_tag).await?;
752	let Some(email) = profile.email else {
753		warn!(tn_id = %row.tn_id.0, id_tag = %row.id_tag,
754			"Cannot send renewal-failure email: tenant has no email on file");
755		return Ok(());
756	};
757
758	// Pull the user's preferred language directly via the settings service —
759	// we can't depend on cloudillo-email here.
760	let lang = match app.settings.get(row.tn_id, "profile.lang").await {
761		Ok(Some(crate::settings::SettingValue::String(s))) => Some(s),
762		_ => None,
763	};
764
765	let base_id_tag = app.opts.base_id_tag.as_ref().map_or("cloudillo", AsRef::as_ref);
766	let local_address_str =
767		app.opts.local_address.iter().map(AsRef::as_ref).collect::<Vec<_>>().join(", ");
768	let domain_for_display = format!("cl-o.{}", row.id_tag);
769
770	let template_vars = serde_json::json!({
771		"idTag": row.id_tag.as_ref(),
772		"domain": domain_for_display,
773		"daysUntilExpiry": days_until_expiry,
774		"expiresAt": expires_at.to_iso_string(),
775		"errorReason": reason,
776		"suspended": suspended,
777		"localAddress": local_address_str,
778		"base_id_tag": base_id_tag,
779		"instance_name": "Cloudillo",
780	});
781
782	let params = ScheduleEmailParams {
783		to: email.to_string(),
784		template_name: "cert_renewal_failed".to_string(),
785		template_vars,
786		lang,
787		// Once-per-day key so we don't queue duplicate emails when the task
788		// runs multiple times before sending (failure_count + day stamp).
789		custom_key: Some(format!(
790			"cert-renewal-failed:{}:{}",
791			row.tn_id.0,
792			Timestamp::now().0 / 86400
793		)),
794		from_name_override: Some(format!("Cloudillo | {}", base_id_tag.to_uppercase())),
795	};
796
797	schedule_email(app, row.tn_id, params).await
798}
799
800#[cfg(test)]
801mod tests {
802	use super::*;
803
804	#[test]
805	fn builds_ascii_domains_for_an_idn_id_tag() {
806		// Both entries derived from the id_tag must be A-labels: ACME identifiers
807		// and the TLS SNI name they end up matching are DNS names, never U-labels.
808		assert_eq!(
809			build_domains_for_tenant("münchen.example.com", None),
810			vec!["cl-o.xn--mnchen-3ya.example.com", "xn--mnchen-3ya.example.com"]
811		);
812	}
813
814	#[test]
815	fn keeps_a_configured_app_domain_verbatim() {
816		// `app_domain` is an operator-configured hostname, not an id_tag.
817		assert_eq!(
818			build_domains_for_tenant("münchen.example.com", Some("app.example.com")),
819			vec!["cl-o.xn--mnchen-3ya.example.com", "app.example.com"]
820		);
821	}
822
823	#[test]
824	fn passes_an_ascii_id_tag_through_unchanged() {
825		assert_eq!(
826			build_domains_for_tenant("alice.example.com", None),
827			vec!["cl-o.alice.example.com", "alice.example.com"]
828		);
829	}
830}
831
832// vim: ts=4