1use std::sync::Arc;
18use std::time::Duration;
19
20use chrono::{DateTime, Utc};
21use sqlx::{PgPool, Row};
22use tokio_util::sync::CancellationToken;
23use uuid::Uuid;
24
25use crate::error::ProxyResult;
26use crate::tls::AcmeIssuer;
27
28const POLL_INTERVAL: Duration = Duration::from_secs(30);
30
31const MAX_ATTEMPTS: i32 = 10;
37
38const STUCK_AFTER: chrono::TimeDelta = chrono::TimeDelta::minutes(15);
53
54const RENEW_WITHIN: chrono::TimeDelta = chrono::TimeDelta::days(30);
59
60const BASE_BACKOFF: Duration = Duration::from_secs(60);
65
66const MAX_BACKOFF: Duration = Duration::from_secs(4 * 60 * 60);
72
73fn backoff(attempts: i32) -> Duration {
82 let shift = attempts.clamp(0, 12) as u32;
83 Duration::from_secs(BASE_BACKOFF.as_secs() << shift).min(MAX_BACKOFF)
84}
85
86struct Claim {
88 id: Uuid,
89 domain: String,
90 attempts: i32,
91}
92
93pub async fn run(pool: PgPool, issuer: Arc<AcmeIssuer>, cancel: CancellationToken) {
99 tracing::info!(
100 directory = %issuer.directory_url(),
101 "ACME issuance worker started"
102 );
103
104 loop {
105 tokio::select! {
106 _ = cancel.cancelled() => {
107 tracing::info!("ACME issuance worker stopped");
108 return;
109 }
110 _ = tokio::time::sleep(POLL_INTERVAL) => {}
111 }
112
113 if let Err(error) = tick(&pool, &issuer).await {
114 tracing::error!(%error, "ACME issuance pass failed");
115 }
116 }
117}
118
119async fn tick(pool: &PgPool, issuer: &AcmeIssuer) -> ProxyResult<()> {
121 let recovered = reclaim_abandoned(pool).await?;
122 if recovered > 0 {
123 tracing::warn!(
124 recovered,
125 "domains were left mid-issuance and have been requeued; \
126 a restart or a database error interrupted an order"
127 );
128 }
129
130 let renewals = mark_renewals(pool).await?;
131 if renewals > 0 {
132 tracing::info!(renewals, "certificates due for renewal");
133 }
134
135 while let Some(claim) = claim_next(pool).await? {
136 match issuer.issue(&claim.domain).await {
137 Ok(certificate) => {
138 mark_active(pool, claim.id, &claim.domain, certificate.expires_at).await?;
139 tracing::info!(
140 domain = %claim.domain,
141 expires_at = ?certificate.expires_at,
142 "certificate active"
143 );
144 }
145 Err(error) => {
146 let attempts = claim.attempts + 1;
147 let give_up = attempts >= MAX_ATTEMPTS;
148 mark_failed(pool, claim.id, &error.to_string(), attempts).await?;
149 if give_up {
150 tracing::error!(
151 domain = %claim.domain,
152 attempts,
153 %error,
154 "giving up on issuance; use the ssl/retry endpoint after fixing the cause"
155 );
156 } else {
157 tracing::warn!(
158 domain = %claim.domain,
159 attempts,
160 retry_in = ?backoff(attempts),
161 %error,
162 "issuance failed, will retry"
163 );
164 }
165 }
166 }
167 }
168
169 Ok(())
170}
171
172async fn claim_next(pool: &PgPool) -> ProxyResult<Option<Claim>> {
199 let row = sqlx::query(
200 "UPDATE proxy_domains SET \
201 ssl_status = 'provisioning', \
202 ssl_last_attempt_at = NOW(), \
203 updated_at = NOW() \
204 WHERE id = ( \
205 SELECT id FROM proxy_domains \
206 WHERE verification_status = 'verified' \
207 AND ssl_provider = 'acme' \
208 AND ssl_status = 'pending' \
209 AND enabled \
210 AND EXISTS ( \
211 SELECT 1 FROM proxy_tenants t \
212 WHERE t.id = proxy_domains.tenant_id AND t.status = 'active') \
213 AND ssl_attempts < $1 \
214 AND ( \
215 ssl_last_attempt_at IS NULL \
216 OR ssl_last_attempt_at < NOW() - make_interval( \
217 secs => LEAST($2 * (2 ^ ssl_attempts), $3) \
218 ) \
219 ) \
220 ORDER BY ssl_last_attempt_at ASC NULLS FIRST \
221 FOR UPDATE SKIP LOCKED \
222 LIMIT 1 \
223 ) \
224 RETURNING id, domain, ssl_attempts",
225 )
226 .bind(MAX_ATTEMPTS)
227 .bind(BASE_BACKOFF.as_secs() as f64)
228 .bind(MAX_BACKOFF.as_secs() as f64)
229 .fetch_optional(pool)
230 .await?;
231
232 let Some(row) = row else { return Ok(None) };
233 Ok(Some(Claim {
234 id: row.try_get("id")?,
235 domain: row.try_get("domain")?,
236 attempts: row.try_get("ssl_attempts")?,
237 }))
238}
239
240async fn mark_active(
251 pool: &PgPool,
252 id: Uuid,
253 domain: &str,
254 expires_at: Option<DateTime<Utc>>,
255) -> ProxyResult<()> {
256 let mut tx = pool.begin().await?;
257 let updated = sqlx::query(
258 "UPDATE proxy_domains SET \
259 ssl_status = 'active', \
260 ssl_expires_at = $2, \
261 ssl_error = NULL, \
262 ssl_attempts = 0, \
263 updated_at = NOW() \
264 WHERE id = $1",
265 )
266 .bind(id)
267 .bind(expires_at)
268 .execute(&mut *tx)
269 .await?;
270
271 if updated.rows_affected() == 0 {
272 sqlx::query("DELETE FROM proxy_certificates WHERE domain = $1 AND domain_id IS NULL")
273 .bind(domain)
274 .execute(&mut *tx)
275 .await?;
276 tx.commit().await?;
277 tracing::warn!(
278 %domain,
279 "the domain was deleted during issuance; removed the certificate issued for it"
280 );
281 return Ok(());
282 }
283
284 sqlx::query(
285 "UPDATE proxy_certificates c SET \
286 domain_id = d.id, \
287 tenant_id = d.tenant_id, \
288 provider = 'acme', \
289 auto_renew = true, \
290 updated_at = NOW() \
291 FROM proxy_domains d \
292 WHERE d.id = $1 AND c.domain = d.domain",
293 )
294 .bind(id)
295 .execute(&mut *tx)
296 .await?;
297 tx.commit().await?;
298 Ok(())
299}
300
301async fn mark_failed(pool: &PgPool, id: Uuid, error: &str, attempts: i32) -> ProxyResult<()> {
308 let status = if attempts >= MAX_ATTEMPTS {
309 "failed"
310 } else {
311 "pending"
312 };
313
314 sqlx::query(
315 "UPDATE proxy_domains SET \
316 ssl_status = $3, \
317 ssl_error = $2, \
318 ssl_attempts = $4, \
319 updated_at = NOW() \
320 WHERE id = $1",
321 )
322 .bind(id)
323 .bind(error)
324 .bind(status)
325 .bind(attempts)
326 .execute(pool)
327 .await?;
328 Ok(())
329}
330
331async fn reclaim_abandoned(pool: &PgPool) -> ProxyResult<u64> {
338 let result = sqlx::query(
339 "UPDATE proxy_domains SET \
340 ssl_status = 'pending', \
341 ssl_attempts = ssl_attempts + 1, \
342 ssl_error = 'issuance was interrupted before it finished', \
343 updated_at = NOW() \
344 WHERE ssl_status = 'provisioning' \
345 AND ssl_provider = 'acme' \
346 AND ssl_last_attempt_at IS NOT NULL \
347 AND ssl_last_attempt_at < NOW() - make_interval(secs => $1)",
348 )
349 .bind(STUCK_AFTER.num_seconds() as f64)
350 .execute(pool)
351 .await?;
352
353 Ok(result.rows_affected())
354}
355
356async fn mark_renewals(pool: &PgPool) -> ProxyResult<u64> {
365 let deadline = Utc::now() + RENEW_WITHIN;
366
367 let result = sqlx::query(
368 "UPDATE proxy_domains d SET \
369 ssl_status = 'pending', \
370 ssl_attempts = 0, \
371 updated_at = NOW() \
372 WHERE d.ssl_status = 'active' \
373 AND d.ssl_provider = 'acme' \
374 AND d.verification_status = 'verified' \
375 AND d.enabled \
376 AND EXISTS ( \
377 SELECT 1 FROM proxy_tenants t \
378 WHERE t.id = d.tenant_id AND t.status = 'active') \
379 AND EXISTS ( \
380 SELECT 1 FROM proxy_certificates c \
381 WHERE c.domain = d.domain \
382 AND c.auto_renew \
383 AND c.expires_at IS NOT NULL \
384 AND c.expires_at < $1 \
385 )",
386 )
387 .bind(deadline)
388 .execute(pool)
389 .await?;
390
391 Ok(result.rows_affected())
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn backoff_grows_then_stops() {
400 assert_eq!(backoff(0), Duration::from_secs(60));
402 assert_eq!(backoff(1), Duration::from_secs(120));
403 assert_eq!(backoff(4), Duration::from_secs(16 * 60));
404
405 assert_eq!(backoff(8), MAX_BACKOFF);
408 assert_eq!(backoff(20), MAX_BACKOFF);
409 assert_eq!(backoff(i32::MAX), MAX_BACKOFF);
410 }
411
412 #[test]
413 fn the_cap_is_reachable() {
414 let first_capped = (0..=MAX_ATTEMPTS)
419 .find(|n| backoff(*n) == MAX_BACKOFF)
420 .expect("the cap is never reached within MAX_ATTEMPTS");
421 assert!(
422 first_capped < MAX_ATTEMPTS,
423 "capped only at the last attempt"
424 );
425 }
426
427 #[test]
428 fn backoff_never_panics_on_a_negative_count() {
429 assert_eq!(backoff(-1), Duration::from_secs(60));
432 assert_eq!(backoff(i32::MIN), Duration::from_secs(60));
433 }
434
435 #[test]
436 fn ten_attempts_of_backoff_span_about_a_day() {
437 let total: u64 = (0..MAX_ATTEMPTS).map(|n| backoff(n).as_secs()).sum();
440 let hours = total / 3600;
441 assert!(
442 (12..=48).contains(&hours),
443 "ten attempts span {hours}h, which no longer matches the documented ~1 day"
444 );
445 }
446
447 #[test]
448 fn renewal_threshold_leaves_room_to_notice() {
449 assert!(RENEW_WITHIN < chrono::TimeDelta::days(90));
451 assert!(RENEW_WITHIN >= chrono::TimeDelta::days(14));
452 }
453}
454
455#[cfg(test)]
462mod db_tests {
463 use super::*;
464 use sqlx::Executor;
465
466 const MIGRATIONS: [&str; 4] = [
467 include_str!("../../migrations/20240115000001_saas_domains.sql"),
468 include_str!("../../migrations/20260901000001_proxy_config.sql"),
469 include_str!("../../migrations/20260901000002_proxy_acme.sql"),
470 include_str!("../../migrations/20261030000001_proxy_control_plane.sql"),
471 ];
472
473 const MIGRATION_LOCK: i64 = 0x706f7374_72757374;
475
476 const CLAIM_LOCK: i64 = 0x706f7374_61636d65;
485
486 struct Exclusive(#[allow(dead_code)] sqlx::PgConnection);
492
493 async fn exclusive() -> Exclusive {
494 use sqlx::Connection;
495 let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
496 let mut conn = sqlx::PgConnection::connect(&url)
497 .await
498 .expect("could not open a dedicated connection");
499 sqlx::query("SELECT pg_advisory_lock($1)")
500 .bind(CLAIM_LOCK)
501 .execute(&mut conn)
502 .await
503 .expect("could not take the claim lock");
504
505 sqlx::query("DELETE FROM proxy_domains WHERE ssl_provider = 'acme'")
508 .execute(&mut conn)
509 .await
510 .expect("could not clear the queue");
511
512 Exclusive(conn)
513 }
514
515 async fn pool() -> PgPool {
516 let url = std::env::var("DATABASE_URL")
517 .expect("DATABASE_URL must be set for the database-backed tests");
518 let pool = PgPool::connect(&url)
519 .await
520 .expect("could not connect to DATABASE_URL");
521
522 let mut tx = pool.begin().await.expect("begin");
523 sqlx::query("SELECT pg_advisory_xact_lock($1)")
524 .bind(MIGRATION_LOCK)
525 .execute(&mut *tx)
526 .await
527 .expect("migration lock");
528 for migration in MIGRATIONS {
529 (&mut *tx)
530 .execute(migration)
531 .await
532 .expect("could not apply a migration");
533 }
534 tx.commit().await.expect("commit");
535 pool
536 }
537
538 async fn tenant(pool: &PgPool) -> Uuid {
540 sqlx::query_scalar(
541 "INSERT INTO proxy_tenants (name, slug, email) VALUES ($1, $2, $3) RETURNING id",
542 )
543 .bind("worker test")
544 .bind(format!("t-{}", Uuid::new_v4()))
545 .bind("worker@postrust.invalid")
546 .fetch_one(pool)
547 .await
548 .expect("insert tenant")
549 }
550
551 async fn domain(
553 pool: &PgPool,
554 tenant_id: Uuid,
555 ssl_status: &str,
556 attempts: i32,
557 last_attempt_secs_ago: Option<i64>,
558 ) -> (Uuid, String) {
559 let name = format!("d-{}.example", Uuid::new_v4());
560 let id: Uuid = sqlx::query_scalar(
561 "INSERT INTO proxy_domains \
562 (tenant_id, domain, verification_status, verification_method, \
563 verification_token, ssl_status, ssl_provider, ssl_attempts, \
564 ssl_last_attempt_at, enabled) \
565 VALUES ($1, $2, 'verified', 'dns', 'tok', $3, 'acme', $4, \
566 CASE WHEN $5::bigint IS NULL THEN NULL \
567 ELSE NOW() - make_interval(secs => $5::bigint) END, true) \
568 RETURNING id",
569 )
570 .bind(tenant_id)
571 .bind(&name)
572 .bind(ssl_status)
573 .bind(attempts)
574 .bind(last_attempt_secs_ago)
575 .fetch_one(pool)
576 .await
577 .expect("insert domain");
578 (id, name)
579 }
580
581 async fn claims_include(pool: &PgPool, id: Uuid) -> bool {
583 let mut found = false;
584 while let Some(claim) = claim_next(pool).await.expect("claim") {
585 if claim.id == id {
586 found = true;
587 }
588 }
589 found
590 }
591
592 async fn drain(pool: &PgPool) -> Vec<Uuid> {
593 let mut ids = Vec::new();
594 while let Some(claim) = claim_next(pool).await.expect("claim") {
595 ids.push(claim.id);
596 }
597 ids
598 }
599
600 #[tokio::test]
601 #[ignore = "requires PostgreSQL"]
602 async fn a_fresh_pending_domain_is_claimed() {
603 let pool = pool().await;
604 let _exclusive = exclusive().await;
605 let t = tenant(&pool).await;
606 let (id, _) = domain(&pool, t, "pending", 0, None).await;
607
608 assert!(
609 claims_include(&pool, id).await,
610 "a verified ACME domain with no attempts should be claimed"
611 );
612
613 let status: String =
614 sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
615 .bind(id)
616 .fetch_one(&pool)
617 .await
618 .unwrap();
619 assert_eq!(status, "provisioning", "claiming must take the row");
620 }
621
622 #[tokio::test]
623 #[ignore = "requires PostgreSQL"]
624 async fn a_domain_that_failed_recently_waits() {
625 let pool = pool().await;
626 let _exclusive = exclusive().await;
627 let t = tenant(&pool).await;
628 let (id, _) = domain(&pool, t, "pending", 1, Some(10)).await;
630
631 assert!(
632 !claims_include(&pool, id).await,
633 "a domain inside its backoff window should not be claimed"
634 );
635 }
636
637 #[tokio::test]
638 #[ignore = "requires PostgreSQL"]
639 async fn a_domain_whose_backoff_has_elapsed_is_claimed_again() {
640 let pool = pool().await;
641 let _exclusive = exclusive().await;
642 let t = tenant(&pool).await;
643 let (id, _) = domain(&pool, t, "pending", 1, Some(3600)).await;
649
650 assert!(
651 claims_include(&pool, id).await,
652 "a domain past its backoff window must be retried"
653 );
654 }
655
656 #[tokio::test]
657 #[ignore = "requires PostgreSQL"]
658 async fn backoff_grows_with_attempts() {
659 let pool = pool().await;
660 let _exclusive = exclusive().await;
661 let t = tenant(&pool).await;
662
663 let (too_soon, _) = domain(&pool, t, "pending", 4, Some(10 * 60)).await;
666 let (due, _) = domain(&pool, t, "pending", 4, Some(20 * 60)).await;
667
668 let claimed = drain(&pool).await;
669
670 assert!(
671 claimed.contains(&due),
672 "a domain past its grown backoff must be retried"
673 );
674 assert!(
675 !claimed.contains(&too_soon),
676 "the backoff window must grow with the attempt count"
677 );
678 }
679
680 #[tokio::test]
681 #[ignore = "requires PostgreSQL"]
682 async fn the_backoff_is_capped() {
683 let pool = pool().await;
684 let _exclusive = exclusive().await;
685 let t = tenant(&pool).await;
686
687 let (id, _) = domain(&pool, t, "pending", 9, Some(5 * 60 * 60)).await;
690
691 assert!(
692 claims_include(&pool, id).await,
693 "the cap must bind, or a domain waits far longer than intended"
694 );
695 }
696
697 #[tokio::test]
698 #[ignore = "requires PostgreSQL"]
699 async fn a_domain_at_the_attempt_limit_is_left_alone() {
700 let pool = pool().await;
701 let _exclusive = exclusive().await;
702 let t = tenant(&pool).await;
703 let (id, _) = domain(&pool, t, "pending", 10, Some(24 * 60 * 60)).await;
705
706 assert!(
707 !claims_include(&pool, id).await,
708 "a domain at MAX_ATTEMPTS must not be picked up again without a retry"
709 );
710 }
711
712 #[tokio::test]
713 #[ignore = "requires PostgreSQL"]
714 async fn only_pending_acme_domains_are_claimed() {
715 let pool = pool().await;
716 let _exclusive = exclusive().await;
717 let t = tenant(&pool).await;
718
719 let (active, _) = domain(&pool, t, "active", 0, None).await;
720 let (failed, _) = domain(&pool, t, "failed", 0, None).await;
721 let (provisioning, _) = domain(&pool, t, "provisioning", 0, None).await;
722
723 let manual: Uuid = sqlx::query_scalar(
725 "INSERT INTO proxy_domains \
726 (tenant_id, domain, verification_status, verification_token, \
727 ssl_status, ssl_provider) \
728 VALUES ($1, $2, 'verified', 'tok', 'pending', 'manual') RETURNING id",
729 )
730 .bind(t)
731 .bind(format!("manual-{}.example", Uuid::new_v4()))
732 .fetch_one(&pool)
733 .await
734 .unwrap();
735
736 let unverified: Uuid = sqlx::query_scalar(
737 "INSERT INTO proxy_domains \
738 (tenant_id, domain, verification_status, verification_token, \
739 ssl_status, ssl_provider) \
740 VALUES ($1, $2, 'pending', 'tok', 'pending', 'acme') RETURNING id",
741 )
742 .bind(t)
743 .bind(format!("unverified-{}.example", Uuid::new_v4()))
744 .fetch_one(&pool)
745 .await
746 .unwrap();
747
748 let claimed = drain(&pool).await;
749
750 for (id, why) in [
751 (active, "already active"),
752 (failed, "given up on"),
753 (provisioning, "already being worked on"),
754 (manual, "not an ACME domain"),
755 (unverified, "not verified"),
756 ] {
757 assert!(!claimed.contains(&id), "claimed a domain that is {why}");
758 }
759 }
760
761 #[tokio::test]
762 #[ignore = "requires PostgreSQL"]
763 async fn provision_requeues_a_failed_domain_and_clears_its_history() {
764 let pool = pool().await;
765 let _exclusive = exclusive().await;
766 let t = tenant(&pool).await;
767 let (id, _) = domain(&pool, t, "failed", 10, Some(60)).await;
768 sqlx::query("UPDATE proxy_domains SET ssl_error = 'something' WHERE id = $1")
769 .bind(id)
770 .execute(&pool)
771 .await
772 .unwrap();
773
774 let requeued = crate::saas::db::queue_for_issuance(&pool, id, t)
775 .await
776 .expect("retry");
777 assert!(requeued.is_some(), "provision should find the domain");
778
779 let row = sqlx::query(
780 "SELECT ssl_status, ssl_attempts, ssl_error, ssl_last_attempt_at \
781 FROM proxy_domains WHERE id = $1",
782 )
783 .bind(id)
784 .fetch_one(&pool)
785 .await
786 .unwrap();
787
788 use sqlx::Row;
789 assert_eq!(row.get::<String, _>("ssl_status"), "pending");
790 assert_eq!(row.get::<i32, _>("ssl_attempts"), 0);
791 assert!(row.get::<Option<String>, _>("ssl_error").is_none());
792 assert!(row
795 .get::<Option<chrono::DateTime<chrono::Utc>>, _>("ssl_last_attempt_at")
796 .is_none());
797
798 assert!(
799 claims_include(&pool, id).await,
800 "a retried domain must be claimable straight away"
801 );
802 }
803
804 #[tokio::test]
805 #[ignore = "requires PostgreSQL"]
806 async fn a_domain_abandoned_mid_issuance_is_requeued() {
807 let pool = pool().await;
808 let _exclusive = exclusive().await;
809 let t = tenant(&pool).await;
810
811 let (id, _) = domain(&pool, t, "provisioning", 0, Some(3600)).await;
816
817 let recovered = reclaim_abandoned(&pool).await.expect("reclaim");
818 assert_eq!(recovered, 1, "the abandoned domain was not requeued");
819
820 assert!(
821 claims_include(&pool, id).await,
822 "a requeued domain must be claimable again"
823 );
824
825 let attempts: i32 =
826 sqlx::query_scalar("SELECT ssl_attempts FROM proxy_domains WHERE id = $1")
827 .bind(id)
828 .fetch_one(&pool)
829 .await
830 .unwrap();
831 assert_eq!(attempts, 1, "the interrupted attempt was not counted");
834 }
835
836 #[tokio::test]
837 #[ignore = "requires PostgreSQL"]
838 async fn a_live_order_is_not_stolen_from_the_worker_running_it() {
839 let pool = pool().await;
840 let _exclusive = exclusive().await;
841 let t = tenant(&pool).await;
842
843 let (id, _) = domain(&pool, t, "provisioning", 0, Some(60)).await;
847
848 let recovered = reclaim_abandoned(&pool).await.expect("reclaim");
849 assert_eq!(recovered, 0, "a live order was reclaimed");
850
851 let status: String =
852 sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
853 .bind(id)
854 .fetch_one(&pool)
855 .await
856 .unwrap();
857 assert_eq!(status, "provisioning");
858 }
859
860 #[tokio::test]
861 #[ignore = "requires PostgreSQL"]
862 async fn reclaiming_leaves_other_providers_and_states_alone() {
863 let pool = pool().await;
864 let _exclusive = exclusive().await;
865 let t = tenant(&pool).await;
866
867 let (pending, _) = domain(&pool, t, "pending", 0, Some(3600)).await;
868 let (active, _) = domain(&pool, t, "active", 0, Some(3600)).await;
869
870 let manual: Uuid = sqlx::query_scalar(
873 "INSERT INTO proxy_domains \
874 (tenant_id, domain, verification_status, verification_token, \
875 ssl_status, ssl_provider, ssl_last_attempt_at) \
876 VALUES ($1, $2, 'verified', 'tok', 'provisioning', 'manual', \
877 NOW() - make_interval(secs => 3600)) RETURNING id",
878 )
879 .bind(t)
880 .bind(format!("manual-{}.example", Uuid::new_v4()))
881 .fetch_one(&pool)
882 .await
883 .unwrap();
884
885 let recovered = reclaim_abandoned(&pool).await.expect("reclaim");
886 assert_eq!(recovered, 0, "reclaimed something it should not have");
887
888 for (id, expected) in [
889 (pending, "pending"),
890 (active, "active"),
891 (manual, "provisioning"),
892 ] {
893 let status: String =
894 sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
895 .bind(id)
896 .fetch_one(&pool)
897 .await
898 .unwrap();
899 assert_eq!(
900 status, expected,
901 "a row was changed that should not have been"
902 );
903 }
904 }
905
906 #[tokio::test]
907 #[ignore = "requires PostgreSQL"]
908 async fn provision_refuses_a_domain_that_is_not_verified() {
909 let pool = pool().await;
910 let _exclusive = exclusive().await;
911 let t = tenant(&pool).await;
912
913 let id: Uuid = sqlx::query_scalar(
914 "INSERT INTO proxy_domains \
915 (tenant_id, domain, verification_status, verification_token, \
916 ssl_status, ssl_provider) \
917 VALUES ($1, $2, 'pending', 'tok', 'pending', 'acme') RETURNING id",
918 )
919 .bind(t)
920 .bind(format!("unverified-{}.example", Uuid::new_v4()))
921 .fetch_one(&pool)
922 .await
923 .unwrap();
924
925 let queued = crate::saas::db::queue_for_issuance(&pool, id, t)
929 .await
930 .expect("queue");
931 assert!(queued.is_none(), "queued an unverified domain");
932 }
933
934 #[tokio::test]
935 #[ignore = "requires PostgreSQL"]
936 async fn provision_refuses_a_manual_domain() {
937 let pool = pool().await;
938 let _exclusive = exclusive().await;
939 let t = tenant(&pool).await;
940
941 let id: Uuid = sqlx::query_scalar(
942 "INSERT INTO proxy_domains \
943 (tenant_id, domain, verification_status, verification_token, \
944 ssl_status, ssl_provider) \
945 VALUES ($1, $2, 'verified', 'tok', 'pending', 'manual') RETURNING id",
946 )
947 .bind(t)
948 .bind(format!("manual-{}.example", Uuid::new_v4()))
949 .fetch_one(&pool)
950 .await
951 .unwrap();
952
953 let queued = crate::saas::db::queue_for_issuance(&pool, id, t)
954 .await
955 .expect("queue");
956 assert!(
957 queued.is_none(),
958 "queued a domain whose certificate does not come from ACME"
959 );
960 }
961
962 #[tokio::test]
963 #[ignore = "requires PostgreSQL"]
964 async fn switching_a_verified_domain_to_acme_queues_it() {
965 let pool = pool().await;
966 let _exclusive = exclusive().await;
967 let t = tenant(&pool).await;
968
969 let id: Uuid = sqlx::query_scalar(
971 "INSERT INTO proxy_domains \
972 (tenant_id, domain, verification_status, verification_token, \
973 ssl_status, ssl_provider, ssl_attempts, ssl_error, enabled) \
974 VALUES ($1, $2, 'verified', 'tok', 'active', 'manual', 4, 'old', true) \
975 RETURNING id",
976 )
977 .bind(t)
978 .bind(format!("switch-{}.example", Uuid::new_v4()))
979 .fetch_one(&pool)
980 .await
981 .unwrap();
982
983 let updated = crate::saas::db::update_domain(
984 &pool,
985 id,
986 t,
987 None,
988 Some(&crate::saas::types::SslProvider::Acme),
989 )
990 .await
991 .expect("update")
992 .expect("domain should be found");
993
994 assert_eq!(
997 format!("{:?}", updated.ssl_status),
998 "Pending",
999 "switching to acme must queue the domain"
1000 );
1001 assert!(
1002 claims_include(&pool, id).await,
1003 "a domain switched to acme must become claimable"
1004 );
1005
1006 let row = sqlx::query("SELECT ssl_attempts, ssl_error FROM proxy_domains WHERE id = $1")
1007 .bind(id)
1008 .fetch_one(&pool)
1009 .await
1010 .unwrap();
1011 use sqlx::Row as _;
1012 assert_eq!(row.get::<i32, _>("ssl_attempts"), 0, "history not cleared");
1013 assert!(row.get::<Option<String>, _>("ssl_error").is_none());
1014 }
1015
1016 #[tokio::test]
1017 #[ignore = "requires PostgreSQL"]
1018 async fn switching_an_unverified_domain_to_acme_does_not_queue_it() {
1019 let pool = pool().await;
1020 let _exclusive = exclusive().await;
1021 let t = tenant(&pool).await;
1022
1023 let id: Uuid = sqlx::query_scalar(
1024 "INSERT INTO proxy_domains \
1025 (tenant_id, domain, verification_status, verification_token, \
1026 ssl_status, ssl_provider) \
1027 VALUES ($1, $2, 'pending', 'tok', 'pending', 'manual') RETURNING id",
1028 )
1029 .bind(t)
1030 .bind(format!("switch-unverified-{}.example", Uuid::new_v4()))
1031 .fetch_one(&pool)
1032 .await
1033 .unwrap();
1034
1035 crate::saas::db::update_domain(
1036 &pool,
1037 id,
1038 t,
1039 None,
1040 Some(&crate::saas::types::SslProvider::Acme),
1041 )
1042 .await
1043 .expect("update")
1044 .expect("domain should be found");
1045
1046 assert!(
1047 !claims_include(&pool, id).await,
1048 "an unverified domain must not become claimable by changing its provider"
1049 );
1050 }
1051
1052 #[tokio::test]
1053 #[ignore = "requires PostgreSQL"]
1054 async fn an_update_leaves_absent_fields_alone() {
1055 let pool = pool().await;
1056 let _exclusive = exclusive().await;
1057 let t = tenant(&pool).await;
1058 let (id, _) = domain(&pool, t, "pending", 3, Some(10)).await;
1059
1060 let updated = crate::saas::db::update_domain(
1063 &pool,
1064 id,
1065 t,
1066 Some(&crate::saas::types::VerificationMethod::Http),
1067 None,
1068 )
1069 .await
1070 .expect("update")
1071 .expect("domain should be found");
1072
1073 assert_eq!(format!("{:?}", updated.verification_method), "Http");
1074 assert_eq!(format!("{:?}", updated.ssl_provider), "Acme");
1075
1076 let attempts: i32 =
1077 sqlx::query_scalar("SELECT ssl_attempts FROM proxy_domains WHERE id = $1")
1078 .bind(id)
1079 .fetch_one(&pool)
1080 .await
1081 .unwrap();
1082 assert_eq!(attempts, 3, "an unrelated update reset the attempt count");
1083 }
1084
1085 #[tokio::test]
1086 #[ignore = "requires PostgreSQL"]
1087 async fn an_update_will_not_cross_a_tenant_boundary() {
1088 let pool = pool().await;
1089 let _exclusive = exclusive().await;
1090 let owner = tenant(&pool).await;
1091 let stranger = tenant(&pool).await;
1092 let (id, _) = domain(&pool, owner, "pending", 0, None).await;
1093
1094 let updated = crate::saas::db::update_domain(
1095 &pool,
1096 id,
1097 stranger,
1098 None,
1099 Some(&crate::saas::types::SslProvider::None),
1100 )
1101 .await
1102 .expect("update");
1103 assert!(updated.is_none(), "update crossed a tenant boundary");
1104 }
1105
1106 #[tokio::test]
1107 #[ignore = "requires PostgreSQL"]
1108 async fn provision_will_not_touch_another_tenants_domain() {
1109 let pool = pool().await;
1110 let _exclusive = exclusive().await;
1111 let owner = tenant(&pool).await;
1112 let stranger = tenant(&pool).await;
1113 let (id, _) = domain(&pool, owner, "failed", 10, Some(60)).await;
1114
1115 let requeued = crate::saas::db::queue_for_issuance(&pool, id, stranger)
1116 .await
1117 .expect("retry");
1118 assert!(requeued.is_none(), "provision crossed a tenant boundary");
1119
1120 let status: String =
1121 sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
1122 .bind(id)
1123 .fetch_one(&pool)
1124 .await
1125 .unwrap();
1126 assert_eq!(status, "failed", "another tenant's domain was modified");
1127 }
1128
1129 #[tokio::test]
1130 #[ignore = "requires PostgreSQL"]
1131 async fn two_workers_do_not_claim_the_same_domain() {
1132 let pool = pool().await;
1133 let _exclusive = exclusive().await;
1134 let t = tenant(&pool).await;
1135 let (id, _) = domain(&pool, t, "pending", 0, None).await;
1136
1137 let first = claim_next(&pool).await.expect("claim");
1141 assert!(first.is_some());
1142
1143 let others = drain(&pool).await;
1144 assert!(!others.contains(&id), "the same domain was claimed twice");
1145 }
1146
1147 #[tokio::test]
1148 #[ignore = "requires PostgreSQL"]
1149 async fn a_disabled_domain_is_not_claimed() {
1150 let pool = pool().await;
1151 let _exclusive = exclusive().await;
1152 let t = tenant(&pool).await;
1153 let (id, _) = domain(&pool, t, "pending", 0, None).await;
1154 sqlx::query("UPDATE proxy_domains SET enabled = false WHERE id = $1")
1155 .bind(id)
1156 .execute(&pool)
1157 .await
1158 .unwrap();
1159
1160 assert!(
1161 !claims_include(&pool, id).await,
1162 "a domain its tenant has disabled was claimed for issuance"
1163 );
1164
1165 sqlx::query("UPDATE proxy_domains SET enabled = true WHERE id = $1")
1167 .bind(id)
1168 .execute(&pool)
1169 .await
1170 .unwrap();
1171 assert!(claims_include(&pool, id).await);
1172 }
1173
1174 #[tokio::test]
1175 #[ignore = "requires PostgreSQL"]
1176 async fn a_suspended_tenants_domain_is_not_claimed_or_renewed() {
1177 let pool = pool().await;
1178 let _exclusive = exclusive().await;
1179 let t = tenant(&pool).await;
1180 let (pending, _) = domain(&pool, t, "pending", 0, None).await;
1181 let (active, name) = domain(&pool, t, "active", 0, None).await;
1182 sqlx::query(
1183 "INSERT INTO proxy_certificates (domain, cert_pem, key_pem, expires_at, domain_id) \
1184 VALUES ($1, 'c', 'k', NOW() + INTERVAL '1 day', $2)",
1185 )
1186 .bind(&name)
1187 .bind(active)
1188 .execute(&pool)
1189 .await
1190 .unwrap();
1191 sqlx::query("UPDATE proxy_tenants SET status = 'suspended' WHERE id = $1")
1192 .bind(t)
1193 .execute(&pool)
1194 .await
1195 .unwrap();
1196
1197 assert!(
1198 !claims_include(&pool, pending).await,
1199 "a suspended tenant's domain was claimed for issuance"
1200 );
1201 mark_renewals(&pool).await.expect("renewals");
1202 let status: String =
1203 sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
1204 .bind(active)
1205 .fetch_one(&pool)
1206 .await
1207 .unwrap();
1208 assert_eq!(
1209 status, "active",
1210 "a suspended tenant's certificate was queued for renewal"
1211 );
1212 }
1213
1214 #[tokio::test]
1215 #[ignore = "requires PostgreSQL"]
1216 async fn an_issued_certificate_is_linked_to_its_domain_and_goes_with_it() {
1217 let pool = pool().await;
1218 let _exclusive = exclusive().await;
1219 let t = tenant(&pool).await;
1220 let (id, name) = domain(&pool, t, "provisioning", 0, Some(1)).await;
1221
1222 sqlx::query(
1224 "INSERT INTO proxy_certificates (domain, cert_pem, key_pem) VALUES ($1, 'c', 'k')",
1225 )
1226 .bind(&name)
1227 .execute(&pool)
1228 .await
1229 .unwrap();
1230
1231 mark_active(&pool, id, &name, None)
1232 .await
1233 .expect("mark active");
1234
1235 let (domain_id, tenant_id): (Option<Uuid>, Option<Uuid>) =
1236 sqlx::query_as("SELECT domain_id, tenant_id FROM proxy_certificates WHERE domain = $1")
1237 .bind(&name)
1238 .fetch_one(&pool)
1239 .await
1240 .unwrap();
1241 assert_eq!(domain_id, Some(id));
1242 assert_eq!(tenant_id, Some(t));
1243
1244 sqlx::query("DELETE FROM proxy_domains WHERE id = $1")
1245 .bind(id)
1246 .execute(&pool)
1247 .await
1248 .unwrap();
1249 let left: i64 =
1250 sqlx::query_scalar("SELECT COUNT(*) FROM proxy_certificates WHERE domain = $1")
1251 .bind(&name)
1252 .fetch_one(&pool)
1253 .await
1254 .unwrap();
1255 assert_eq!(left, 0, "the certificate outlived its domain");
1256 }
1257
1258 #[tokio::test]
1259 #[ignore = "requires PostgreSQL"]
1260 async fn a_certificate_issued_for_a_domain_deleted_mid_order_is_removed() {
1261 let pool = pool().await;
1262 let _exclusive = exclusive().await;
1263 let t = tenant(&pool).await;
1264 let (id, name) = domain(&pool, t, "provisioning", 0, Some(1)).await;
1265 sqlx::query("DELETE FROM proxy_domains WHERE id = $1")
1266 .bind(id)
1267 .execute(&pool)
1268 .await
1269 .unwrap();
1270 sqlx::query(
1271 "INSERT INTO proxy_certificates (domain, cert_pem, key_pem) VALUES ($1, 'c', 'k')",
1272 )
1273 .bind(&name)
1274 .execute(&pool)
1275 .await
1276 .unwrap();
1277
1278 mark_active(&pool, id, &name, None)
1279 .await
1280 .expect("mark active");
1281
1282 let left: i64 =
1283 sqlx::query_scalar("SELECT COUNT(*) FROM proxy_certificates WHERE domain = $1")
1284 .bind(&name)
1285 .fetch_one(&pool)
1286 .await
1287 .unwrap();
1288 assert_eq!(
1289 left, 0,
1290 "a certificate for a deleted domain was left to be served"
1291 );
1292 }
1293}