Skip to main content

postrust_proxy/saas/
ssl.rs

1//! The background worker that obtains certificates for verified domains.
2//!
3//! Issuance is not a request. An ACME order involves several round trips to the
4//! CA, a challenge the CA has to fetch back over the network, and rate limits
5//! that make retrying-with-backoff the only sane failure mode. None of that fits
6//! inside an HTTP handler: a thirty-second order would hold the request open,
7//! and a client timeout would abandon an order the CA had already started.
8//!
9//! So the API only ever sets state. Verifying a domain with
10//! `ssl_provider = 'acme'` leaves it `pending`; this worker drains `pending`,
11//! and `POST /domains/{id}/ssl/retry` puts a `failed` one back.
12//!
13//! That is also why there is no `POST /domains/{id}/ssl/provision`. An older
14//! version of `docs/saas-domains.md` documented one, and it was never in the
15//! router.
16
17use 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
28/// How often to look for work.
29const POLL_INTERVAL: Duration = Duration::from_secs(30);
30
31/// Give up on a domain after this many consecutive failures.
32///
33/// The usual cause of failure is that the domain's DNS was never pointed at
34/// this proxy, which no amount of retrying fixes. Ten attempts with the backoff
35/// below spans about a day.
36const MAX_ATTEMPTS: i32 = 10;
37
38/// How long a domain may sit in `provisioning` before it is considered
39/// abandoned and put back.
40///
41/// Nothing moves a row out of `provisioning` except this worker finishing with
42/// it, and both of those writes happen in-process after the order returns. A
43/// restart mid-order -- an order takes tens of seconds, and deploys happen --
44/// or a database error on the way to recording the result leaves the row there
45/// for ever: `claim_next` takes only `pending`, so the domain silently never
46/// gets a certificate and nothing says so.
47///
48/// Comfortably longer than [`ORDER_TIMEOUT`] plus its retries, so a live order
49/// is never stolen from the worker running it.
50///
51/// [`ORDER_TIMEOUT`]: crate::tls
52const STUCK_AFTER: chrono::TimeDelta = chrono::TimeDelta::minutes(15);
53
54/// Renew once a certificate is within this long of expiring.
55///
56/// Let's Encrypt issues for 90 days and recommends renewing at 30, which leaves
57/// a month to notice a problem.
58const RENEW_WITHIN: chrono::TimeDelta = chrono::TimeDelta::days(30);
59
60/// The first gap between attempts, doubled on each failure.
61///
62/// Named because the claim query computes the same curve in SQL, and the two
63/// have to agree.
64const BASE_BACKOFF: Duration = Duration::from_secs(60);
65
66/// The longest gap between attempts.
67///
68/// The cap matters more than the growth: past a point the failure is a human
69/// problem, and hammering the CA for a domain that will never validate spends a
70/// rate limit that the domains which *would* validate have to share.
71const MAX_BACKOFF: Duration = Duration::from_secs(4 * 60 * 60);
72
73/// How long to wait after the nth consecutive failure.
74///
75/// One minute, doubling, capped at [`MAX_BACKOFF`]. The shift is clamped only
76/// to keep it in range -- `1 << 64` is undefined and `1 << -1` panics in debug,
77/// and `ssl_attempts` is a signed column a hand-edited row can make negative.
78/// The clamp is set high enough that the cap is what actually binds; an earlier
79/// version clamped at 8, which held the value below the cap and made the cap
80/// dead code.
81fn 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
86/// A domain the worker has claimed.
87struct Claim {
88    id: Uuid,
89    domain: String,
90    attempts: i32,
91}
92
93/// Run until cancelled.
94///
95/// Errors are logged and the loop continues: a worker that exits on a database
96/// hiccup would silently stop issuing certificates for the life of the process,
97/// which is exactly the class of failure this module exists to remove.
98pub 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
119/// One pass: recover abandoned work, mark renewals, then issue.
120async 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
172/// Claim one domain to work on, atomically.
173///
174/// `FOR UPDATE SKIP LOCKED` inside the same statement that flips the status is
175/// what makes more than one proxy instance safe: two workers cannot claim the
176/// same row, and neither blocks on the other. Without it both would place an
177/// order for the same domain and burn two of the CA's rate-limited slots to get
178/// one certificate.
179///
180/// The backoff is computed **in the query**, from the row's own `ssl_attempts`:
181///
182/// ```text
183/// LEAST(base * 2 ^ attempts, cap)
184/// ```
185///
186/// which mirrors [`backoff`]. It has to be one statement.
187///
188/// Only a domain that is enabled, of a tenant that is active, is claimed. A
189/// disabled domain is one its tenant has taken out of service, and a
190/// suspended tenant is one the operator has; neither should go on spending
191/// the CA's rate limit, or acquire a fresh certificate the TLS listener would
192/// then serve. A domain enabled later is still `pending`, and is picked up
193/// then. An earlier version
194/// claimed the row (setting `ssl_last_attempt_at = NOW()`) and then checked the
195/// backoff in a second query -- against the timestamp it had just overwritten,
196/// so every row with a failure behind it compared `NOW() < NOW() - wait`, was
197/// always ineligible, and was released again. Backoff meant "never retry".
198async 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
240/// Record a successful issuance, and tie the certificate to its domain.
241///
242/// The issuer stores the certificate keyed only by name. Linking it here --
243/// `domain_id` and `tenant_id` -- is what makes deleting the domain delete the
244/// certificate and its key; unlinked, they outlived the domain and the TLS
245/// listener went on serving them.
246///
247/// If the domain was deleted while the order was in flight, there is nothing
248/// to link to, and the certificate just stored is for a name nobody holds any
249/// more. It is removed rather than left for the listener to serve.
250async 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
301/// Record a failure.
302///
303/// Below the attempt limit the row goes back to `pending` so the worker will
304/// come back to it; at the limit it becomes `failed`, which the worker does not
305/// pick up. `ssl_error` carries the reason either way -- it is the only way an
306/// operator finds out why a domain is stuck.
307async 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
331/// Put domains abandoned mid-issuance back in the queue.
332///
333/// Returns how many. The attempt counter is incremented rather than left alone:
334/// the interrupted order was still an order, and a proxy that crash-loops
335/// during issuance would otherwise retry without limit and spend the CA's rate
336/// limit on a problem that is not the CA's.
337async 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
356/// Move `active` domains whose certificate is expiring back to `pending`.
357///
358/// Returns how many. Driven off `proxy_certificates.expires_at` rather than
359/// `proxy_domains.ssl_expires_at` so that a certificate replaced out of band --
360/// uploaded by hand, say -- is still renewed on its real expiry.
361///
362/// Disabled domains and suspended tenants are not renewed, for the reasons
363/// [`claim_next`] does not issue for them.
364async 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        // One minute, doubling.
401        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        // Capped, so a domain that will never validate is neither retried on a
406        // schedule measured in days nor hammered for ever.
407        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        // The bug this guards against: clamping the shift below the point where
415        // the cap binds, which leaves the cap as dead code and silently changes
416        // the longest gap. The first attempt to reach the cap must be within
417        // the attempt limit, or the cap never applies in practice either.
418        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        // ssl_attempts is a signed column, so a hand-edited row can be
430        // negative. `1u64 << -1` would panic in debug.
431        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        // The claim about MAX_ATTEMPTS in the doc comment, checked rather than
438        // asserted in prose.
439        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        // Let's Encrypt issues for 90 days.
450        assert!(RENEW_WITHIN < chrono::TimeDelta::days(90));
451        assert!(RENEW_WITHIN >= chrono::TimeDelta::days(14));
452    }
453}
454
455/// Database-backed tests for the claim query and the retry reset.
456///
457/// `#[ignore]`d: they need PostgreSQL, and run in the same CI job as the other
458/// database tests. They live here rather than in `tests/` so they can call
459/// [`claim_next`] itself -- an integration test would have to duplicate the SQL,
460/// and a duplicate that drifts is a test that passes while production is wrong.
461#[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    /// See `tests/config_persistence.rs` for why the migration is locked.
474    const MIGRATION_LOCK: i64 = 0x706f7374_72757374;
475
476    /// Serialises the tests in this module against each other.
477    ///
478    /// The claim queue is global by design -- `claim_next` takes whatever is
479    /// due, from any tenant -- so these tests cannot be isolated by namespacing
480    /// their rows the way the other database tests are. Run in parallel they
481    /// steal each other's domains, and five of ten fail. Locking is the honest
482    /// fix: it keeps them testing the real global query instead of a filtered
483    /// version written for the tests.
484    const CLAIM_LOCK: i64 = 0x706f7374_61636d65;
485
486    /// Exclusive use of the claim queue, with the queue emptied.
487    ///
488    /// Holds a dedicated connection rather than a pooled one, so that dropping
489    /// the guard closes the connection and PostgreSQL releases the session lock
490    /// on its own -- `Drop` cannot await an unlock.
491    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        // Start from an empty queue. Safe under the lock, and it means a test
506        // that leaves rows behind cannot affect the next one.
507        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    /// A tenant to hang domains off.
539    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    /// A verified, enabled ACME domain with a given attempt history.
552    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    /// Drain the queue and report whether `id` came up.
582    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        // One failure, ten seconds ago. The first backoff is a minute.
629        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        // One failure, an hour ago. The first backoff is a minute, so it is due.
644        //
645        // This is the case the original two-query version could never reach: it
646        // stamped ssl_last_attempt_at = NOW() and then compared against that, so
647        // every row with attempts > 0 looked as though it had just been tried.
648        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        // Four failures: 60 * 2^4 = 16 minutes. Ten minutes ago is too soon;
664        // twenty is due.
665        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        // Nine failures. Uncapped that would be 60 * 2^9 = 8.5 hours; the cap is
688        // four, so five hours ago is due.
689        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        // At the limit, long past any backoff.
704        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        // Not ACME, and not verified: both must be invisible to the worker.
724        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        // Cleared, so the next pass picks it up now rather than waiting out a
793        // backoff computed for a cause the operator has just fixed.
794        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        // Claimed an hour ago and never finished: a restart mid-order, or a
812        // database error on the way to recording the result. Nothing else ever
813        // moves a row out of `provisioning`, so without the reaper this domain
814        // silently never gets a certificate.
815        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        // The interrupted order was still an order. Without counting it, a
832        // proxy crash-looping during issuance would retry without limit.
833        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        // Claimed a minute ago: well inside STUCK_AFTER, so another instance
844        // is plausibly still working on it. Reclaiming here would have two
845        // workers ordering the same certificate.
846        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        // Provisioning, long ago, but not an ACME domain -- this worker has no
871        // business touching it.
872        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        // Queueing an unverified domain would have the worker place an order
926        // for a name nobody has proved control of. The CA would refuse it, but
927        // the attempt still spends a rate limit.
928        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        // Verified, manual, and nowhere near the worker's queue.
970        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        // Otherwise the domain would sit in whatever state the old provider
995        // left it in, and nothing would ever pick it up.
996        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        // Only the verification method. The provider, and everything the
1061        // provider clause would have reset, must be untouched.
1062        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        // Claiming flips the status inside the same statement, so a second claim
1138        // cannot see the row. Without that, two instances would each place an
1139        // order and spend two of the CA's rate-limited slots for one certificate.
1140        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        // Enabling it again is enough: it is still pending.
1166        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        // What `AcmeIssuer::issue` leaves behind: a row keyed only by name.
1223        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}