1use 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
32const ACME_ACCOUNT_VAR: &str = "acme_account";
36
37async 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 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 let mut inserted_identifiers: Vec<Box<str>> = Vec::new();
129 let result = renew_domains_inner(state, account, &domains, &mut inserted_identifiers).await;
130
131 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 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 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 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 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
300pub 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 info!(domain = %domain, "Proxy site certificate renewed successfully");
330 Ok(())
331}
332
333#[derive(Clone, Debug, Serialize, Deserialize)]
341pub struct CertRenewalTask {
342 pub renewal_days: u32,
344 pub acme_email: String,
346}
347
348impl CertRenewalTask {
349 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 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 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; 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#[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 serde_json::to_string(self).unwrap_or_else(|_| "null".to_string())
535 }
536
537 async fn run(&self, app: &App) -> ClResult<()> {
538 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 Err(e)
566 }
567 }
568 }
569}
570
571pub fn register_tasks(app: &App) -> ClResult<()> {
575 app.scheduler.register::<CertRenewalTask>()?;
576 app.scheduler.register::<AcmeEarlyRetryTask>()?;
577 Ok(())
578}
579
580const RENEWAL_NOTIFY_LONG_INTERVAL_SECS: i64 = 7 * 86400;
585const RENEWAL_NOTIFY_SHORT_INTERVAL_SECS: i64 = 86400;
586
587fn 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
602enum PreCheckError {
609 Definitive(String),
610 Transient(String),
611}
612
613async 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 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 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 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 None => (0, true),
691 };
692
693 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 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 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 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 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 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 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