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, 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. An earlier version
187/// claimed the row (setting `ssl_last_attempt_at = NOW()`) and then checked the
188/// backoff in a second query -- against the timestamp it had just overwritten,
189/// so every row with a failure behind it compared `NOW() < NOW() - wait`, was
190/// always ineligible, and was released again. Backoff meant "never retry".
191async fn claim_next(pool: &PgPool) -> ProxyResult<Option<Claim>> {
192    let row = sqlx::query(
193        "UPDATE proxy_domains SET \
194             ssl_status = 'provisioning', \
195             ssl_last_attempt_at = NOW(), \
196             updated_at = NOW() \
197         WHERE id = ( \
198             SELECT id FROM proxy_domains \
199             WHERE verification_status = 'verified' \
200               AND ssl_provider = 'acme' \
201               AND ssl_status = 'pending' \
202               AND ssl_attempts < $1 \
203               AND ( \
204                   ssl_last_attempt_at IS NULL \
205                   OR ssl_last_attempt_at < NOW() - make_interval( \
206                       secs => LEAST($2 * (2 ^ ssl_attempts), $3) \
207                   ) \
208               ) \
209             ORDER BY ssl_last_attempt_at ASC NULLS FIRST \
210             FOR UPDATE SKIP LOCKED \
211             LIMIT 1 \
212         ) \
213         RETURNING id, domain, ssl_attempts",
214    )
215    .bind(MAX_ATTEMPTS)
216    .bind(BASE_BACKOFF.as_secs() as f64)
217    .bind(MAX_BACKOFF.as_secs() as f64)
218    .fetch_optional(pool)
219    .await?;
220
221    let Some(row) = row else { return Ok(None) };
222    Ok(Some(Claim {
223        id: row.try_get("id")?,
224        domain: row.try_get("domain")?,
225        attempts: row.try_get("ssl_attempts")?,
226    }))
227}
228
229async fn mark_active(
230    pool: &PgPool,
231    id: Uuid,
232    expires_at: Option<DateTime<Utc>>,
233) -> ProxyResult<()> {
234    sqlx::query(
235        "UPDATE proxy_domains SET \
236             ssl_status = 'active', \
237             ssl_expires_at = $2, \
238             ssl_error = NULL, \
239             ssl_attempts = 0, \
240             updated_at = NOW() \
241         WHERE id = $1",
242    )
243    .bind(id)
244    .bind(expires_at)
245    .execute(pool)
246    .await?;
247    Ok(())
248}
249
250/// Record a failure.
251///
252/// Below the attempt limit the row goes back to `pending` so the worker will
253/// come back to it; at the limit it becomes `failed`, which the worker does not
254/// pick up. `ssl_error` carries the reason either way -- it is the only way an
255/// operator finds out why a domain is stuck.
256async fn mark_failed(pool: &PgPool, id: Uuid, error: &str, attempts: i32) -> ProxyResult<()> {
257    let status = if attempts >= MAX_ATTEMPTS {
258        "failed"
259    } else {
260        "pending"
261    };
262
263    sqlx::query(
264        "UPDATE proxy_domains SET \
265             ssl_status = $3, \
266             ssl_error = $2, \
267             ssl_attempts = $4, \
268             updated_at = NOW() \
269         WHERE id = $1",
270    )
271    .bind(id)
272    .bind(error)
273    .bind(status)
274    .bind(attempts)
275    .execute(pool)
276    .await?;
277    Ok(())
278}
279
280/// Put domains abandoned mid-issuance back in the queue.
281///
282/// Returns how many. The attempt counter is incremented rather than left alone:
283/// the interrupted order was still an order, and a proxy that crash-loops
284/// during issuance would otherwise retry without limit and spend the CA's rate
285/// limit on a problem that is not the CA's.
286async fn reclaim_abandoned(pool: &PgPool) -> ProxyResult<u64> {
287    let result = sqlx::query(
288        "UPDATE proxy_domains SET \
289             ssl_status = 'pending', \
290             ssl_attempts = ssl_attempts + 1, \
291             ssl_error = 'issuance was interrupted before it finished', \
292             updated_at = NOW() \
293         WHERE ssl_status = 'provisioning' \
294           AND ssl_provider = 'acme' \
295           AND ssl_last_attempt_at IS NOT NULL \
296           AND ssl_last_attempt_at < NOW() - make_interval(secs => $1)",
297    )
298    .bind(STUCK_AFTER.num_seconds() as f64)
299    .execute(pool)
300    .await?;
301
302    Ok(result.rows_affected())
303}
304
305/// Move `active` domains whose certificate is expiring back to `pending`.
306///
307/// Returns how many. Driven off `proxy_certificates.expires_at` rather than
308/// `proxy_domains.ssl_expires_at` so that a certificate replaced out of band --
309/// uploaded by hand, say -- is still renewed on its real expiry.
310async fn mark_renewals(pool: &PgPool) -> ProxyResult<u64> {
311    let deadline = Utc::now() + RENEW_WITHIN;
312
313    let result = sqlx::query(
314        "UPDATE proxy_domains d SET \
315             ssl_status = 'pending', \
316             ssl_attempts = 0, \
317             updated_at = NOW() \
318         WHERE d.ssl_status = 'active' \
319           AND d.ssl_provider = 'acme' \
320           AND d.verification_status = 'verified' \
321           AND EXISTS ( \
322               SELECT 1 FROM proxy_certificates c \
323               WHERE c.domain = d.domain \
324                 AND c.auto_renew \
325                 AND c.expires_at IS NOT NULL \
326                 AND c.expires_at < $1 \
327           )",
328    )
329    .bind(deadline)
330    .execute(pool)
331    .await?;
332
333    Ok(result.rows_affected())
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn backoff_grows_then_stops() {
342        // One minute, doubling.
343        assert_eq!(backoff(0), Duration::from_secs(60));
344        assert_eq!(backoff(1), Duration::from_secs(120));
345        assert_eq!(backoff(4), Duration::from_secs(16 * 60));
346
347        // Capped, so a domain that will never validate is neither retried on a
348        // schedule measured in days nor hammered for ever.
349        assert_eq!(backoff(8), MAX_BACKOFF);
350        assert_eq!(backoff(20), MAX_BACKOFF);
351        assert_eq!(backoff(i32::MAX), MAX_BACKOFF);
352    }
353
354    #[test]
355    fn the_cap_is_reachable() {
356        // The bug this guards against: clamping the shift below the point where
357        // the cap binds, which leaves the cap as dead code and silently changes
358        // the longest gap. The first attempt to reach the cap must be within
359        // the attempt limit, or the cap never applies in practice either.
360        let first_capped = (0..=MAX_ATTEMPTS)
361            .find(|n| backoff(*n) == MAX_BACKOFF)
362            .expect("the cap is never reached within MAX_ATTEMPTS");
363        assert!(
364            first_capped < MAX_ATTEMPTS,
365            "capped only at the last attempt"
366        );
367    }
368
369    #[test]
370    fn backoff_never_panics_on_a_negative_count() {
371        // ssl_attempts is a signed column, so a hand-edited row can be
372        // negative. `1u64 << -1` would panic in debug.
373        assert_eq!(backoff(-1), Duration::from_secs(60));
374        assert_eq!(backoff(i32::MIN), Duration::from_secs(60));
375    }
376
377    #[test]
378    fn ten_attempts_of_backoff_span_about_a_day() {
379        // The claim about MAX_ATTEMPTS in the doc comment, checked rather than
380        // asserted in prose.
381        let total: u64 = (0..MAX_ATTEMPTS).map(|n| backoff(n).as_secs()).sum();
382        let hours = total / 3600;
383        assert!(
384            (12..=48).contains(&hours),
385            "ten attempts span {hours}h, which no longer matches the documented ~1 day"
386        );
387    }
388
389    #[test]
390    fn renewal_threshold_leaves_room_to_notice() {
391        // Let's Encrypt issues for 90 days.
392        assert!(RENEW_WITHIN < chrono::TimeDelta::days(90));
393        assert!(RENEW_WITHIN >= chrono::TimeDelta::days(14));
394    }
395}
396
397/// Database-backed tests for the claim query and the retry reset.
398///
399/// `#[ignore]`d: they need PostgreSQL, and run in the same CI job as the other
400/// database tests. They live here rather than in `tests/` so they can call
401/// [`claim_next`] itself -- an integration test would have to duplicate the SQL,
402/// and a duplicate that drifts is a test that passes while production is wrong.
403#[cfg(test)]
404mod db_tests {
405    use super::*;
406    use sqlx::Executor;
407
408    const MIGRATIONS: [&str; 3] = [
409        include_str!("../../migrations/20240115000001_saas_domains.sql"),
410        include_str!("../../migrations/20260901000001_proxy_config.sql"),
411        include_str!("../../migrations/20260901000002_proxy_acme.sql"),
412    ];
413
414    /// See `tests/config_persistence.rs` for why the migration is locked.
415    const MIGRATION_LOCK: i64 = 0x706f7374_72757374;
416
417    /// Serialises the tests in this module against each other.
418    ///
419    /// The claim queue is global by design -- `claim_next` takes whatever is
420    /// due, from any tenant -- so these tests cannot be isolated by namespacing
421    /// their rows the way the other database tests are. Run in parallel they
422    /// steal each other's domains, and five of ten fail. Locking is the honest
423    /// fix: it keeps them testing the real global query instead of a filtered
424    /// version written for the tests.
425    const CLAIM_LOCK: i64 = 0x706f7374_61636d65;
426
427    /// Exclusive use of the claim queue, with the queue emptied.
428    ///
429    /// Holds a dedicated connection rather than a pooled one, so that dropping
430    /// the guard closes the connection and PostgreSQL releases the session lock
431    /// on its own -- `Drop` cannot await an unlock.
432    struct Exclusive(#[allow(dead_code)] sqlx::PgConnection);
433
434    async fn exclusive() -> Exclusive {
435        use sqlx::Connection;
436        let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
437        let mut conn = sqlx::PgConnection::connect(&url)
438            .await
439            .expect("could not open a dedicated connection");
440        sqlx::query("SELECT pg_advisory_lock($1)")
441            .bind(CLAIM_LOCK)
442            .execute(&mut conn)
443            .await
444            .expect("could not take the claim lock");
445
446        // Start from an empty queue. Safe under the lock, and it means a test
447        // that leaves rows behind cannot affect the next one.
448        sqlx::query("DELETE FROM proxy_domains WHERE ssl_provider = 'acme'")
449            .execute(&mut conn)
450            .await
451            .expect("could not clear the queue");
452
453        Exclusive(conn)
454    }
455
456    async fn pool() -> PgPool {
457        let url = std::env::var("DATABASE_URL")
458            .expect("DATABASE_URL must be set for the database-backed tests");
459        let pool = PgPool::connect(&url)
460            .await
461            .expect("could not connect to DATABASE_URL");
462
463        let mut tx = pool.begin().await.expect("begin");
464        sqlx::query("SELECT pg_advisory_xact_lock($1)")
465            .bind(MIGRATION_LOCK)
466            .execute(&mut *tx)
467            .await
468            .expect("migration lock");
469        for migration in MIGRATIONS {
470            (&mut *tx)
471                .execute(migration)
472                .await
473                .expect("could not apply a migration");
474        }
475        tx.commit().await.expect("commit");
476        pool
477    }
478
479    /// A tenant to hang domains off.
480    async fn tenant(pool: &PgPool) -> Uuid {
481        sqlx::query_scalar(
482            "INSERT INTO proxy_tenants (name, slug, email) VALUES ($1, $2, $3) RETURNING id",
483        )
484        .bind("worker test")
485        .bind(format!("t-{}", Uuid::new_v4()))
486        .bind("worker@postrust.invalid")
487        .fetch_one(pool)
488        .await
489        .expect("insert tenant")
490    }
491
492    /// A verified ACME domain with a given attempt history.
493    async fn domain(
494        pool: &PgPool,
495        tenant_id: Uuid,
496        ssl_status: &str,
497        attempts: i32,
498        last_attempt_secs_ago: Option<i64>,
499    ) -> (Uuid, String) {
500        let name = format!("d-{}.example", Uuid::new_v4());
501        let id: Uuid = sqlx::query_scalar(
502            "INSERT INTO proxy_domains \
503                (tenant_id, domain, verification_status, verification_method, \
504                 verification_token, ssl_status, ssl_provider, ssl_attempts, \
505                 ssl_last_attempt_at) \
506             VALUES ($1, $2, 'verified', 'dns', 'tok', $3, 'acme', $4, \
507                 CASE WHEN $5::bigint IS NULL THEN NULL \
508                      ELSE NOW() - make_interval(secs => $5::bigint) END) \
509             RETURNING id",
510        )
511        .bind(tenant_id)
512        .bind(&name)
513        .bind(ssl_status)
514        .bind(attempts)
515        .bind(last_attempt_secs_ago)
516        .fetch_one(pool)
517        .await
518        .expect("insert domain");
519        (id, name)
520    }
521
522    /// Drain the queue and report whether `id` came up.
523    async fn claims_include(pool: &PgPool, id: Uuid) -> bool {
524        let mut found = false;
525        while let Some(claim) = claim_next(pool).await.expect("claim") {
526            if claim.id == id {
527                found = true;
528            }
529        }
530        found
531    }
532
533    async fn drain(pool: &PgPool) -> Vec<Uuid> {
534        let mut ids = Vec::new();
535        while let Some(claim) = claim_next(pool).await.expect("claim") {
536            ids.push(claim.id);
537        }
538        ids
539    }
540
541    #[tokio::test]
542    #[ignore = "requires PostgreSQL"]
543    async fn a_fresh_pending_domain_is_claimed() {
544        let pool = pool().await;
545        let _exclusive = exclusive().await;
546        let t = tenant(&pool).await;
547        let (id, _) = domain(&pool, t, "pending", 0, None).await;
548
549        assert!(
550            claims_include(&pool, id).await,
551            "a verified ACME domain with no attempts should be claimed"
552        );
553
554        let status: String =
555            sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
556                .bind(id)
557                .fetch_one(&pool)
558                .await
559                .unwrap();
560        assert_eq!(status, "provisioning", "claiming must take the row");
561    }
562
563    #[tokio::test]
564    #[ignore = "requires PostgreSQL"]
565    async fn a_domain_that_failed_recently_waits() {
566        let pool = pool().await;
567        let _exclusive = exclusive().await;
568        let t = tenant(&pool).await;
569        // One failure, ten seconds ago. The first backoff is a minute.
570        let (id, _) = domain(&pool, t, "pending", 1, Some(10)).await;
571
572        assert!(
573            !claims_include(&pool, id).await,
574            "a domain inside its backoff window should not be claimed"
575        );
576    }
577
578    #[tokio::test]
579    #[ignore = "requires PostgreSQL"]
580    async fn a_domain_whose_backoff_has_elapsed_is_claimed_again() {
581        let pool = pool().await;
582        let _exclusive = exclusive().await;
583        let t = tenant(&pool).await;
584        // One failure, an hour ago. The first backoff is a minute, so it is due.
585        //
586        // This is the case the original two-query version could never reach: it
587        // stamped ssl_last_attempt_at = NOW() and then compared against that, so
588        // every row with attempts > 0 looked as though it had just been tried.
589        let (id, _) = domain(&pool, t, "pending", 1, Some(3600)).await;
590
591        assert!(
592            claims_include(&pool, id).await,
593            "a domain past its backoff window must be retried"
594        );
595    }
596
597    #[tokio::test]
598    #[ignore = "requires PostgreSQL"]
599    async fn backoff_grows_with_attempts() {
600        let pool = pool().await;
601        let _exclusive = exclusive().await;
602        let t = tenant(&pool).await;
603
604        // Four failures: 60 * 2^4 = 16 minutes. Ten minutes ago is too soon;
605        // twenty is due.
606        let (too_soon, _) = domain(&pool, t, "pending", 4, Some(10 * 60)).await;
607        let (due, _) = domain(&pool, t, "pending", 4, Some(20 * 60)).await;
608
609        let claimed = drain(&pool).await;
610
611        assert!(
612            claimed.contains(&due),
613            "a domain past its grown backoff must be retried"
614        );
615        assert!(
616            !claimed.contains(&too_soon),
617            "the backoff window must grow with the attempt count"
618        );
619    }
620
621    #[tokio::test]
622    #[ignore = "requires PostgreSQL"]
623    async fn the_backoff_is_capped() {
624        let pool = pool().await;
625        let _exclusive = exclusive().await;
626        let t = tenant(&pool).await;
627
628        // Nine failures. Uncapped that would be 60 * 2^9 = 8.5 hours; the cap is
629        // four, so five hours ago is due.
630        let (id, _) = domain(&pool, t, "pending", 9, Some(5 * 60 * 60)).await;
631
632        assert!(
633            claims_include(&pool, id).await,
634            "the cap must bind, or a domain waits far longer than intended"
635        );
636    }
637
638    #[tokio::test]
639    #[ignore = "requires PostgreSQL"]
640    async fn a_domain_at_the_attempt_limit_is_left_alone() {
641        let pool = pool().await;
642        let _exclusive = exclusive().await;
643        let t = tenant(&pool).await;
644        // At the limit, long past any backoff.
645        let (id, _) = domain(&pool, t, "pending", 10, Some(24 * 60 * 60)).await;
646
647        assert!(
648            !claims_include(&pool, id).await,
649            "a domain at MAX_ATTEMPTS must not be picked up again without a retry"
650        );
651    }
652
653    #[tokio::test]
654    #[ignore = "requires PostgreSQL"]
655    async fn only_pending_acme_domains_are_claimed() {
656        let pool = pool().await;
657        let _exclusive = exclusive().await;
658        let t = tenant(&pool).await;
659
660        let (active, _) = domain(&pool, t, "active", 0, None).await;
661        let (failed, _) = domain(&pool, t, "failed", 0, None).await;
662        let (provisioning, _) = domain(&pool, t, "provisioning", 0, None).await;
663
664        // Not ACME, and not verified: both must be invisible to the worker.
665        let manual: Uuid = sqlx::query_scalar(
666            "INSERT INTO proxy_domains \
667                (tenant_id, domain, verification_status, verification_token, \
668                 ssl_status, ssl_provider) \
669             VALUES ($1, $2, 'verified', 'tok', 'pending', 'manual') RETURNING id",
670        )
671        .bind(t)
672        .bind(format!("manual-{}.example", Uuid::new_v4()))
673        .fetch_one(&pool)
674        .await
675        .unwrap();
676
677        let unverified: Uuid = sqlx::query_scalar(
678            "INSERT INTO proxy_domains \
679                (tenant_id, domain, verification_status, verification_token, \
680                 ssl_status, ssl_provider) \
681             VALUES ($1, $2, 'pending', 'tok', 'pending', 'acme') RETURNING id",
682        )
683        .bind(t)
684        .bind(format!("unverified-{}.example", Uuid::new_v4()))
685        .fetch_one(&pool)
686        .await
687        .unwrap();
688
689        let claimed = drain(&pool).await;
690
691        for (id, why) in [
692            (active, "already active"),
693            (failed, "given up on"),
694            (provisioning, "already being worked on"),
695            (manual, "not an ACME domain"),
696            (unverified, "not verified"),
697        ] {
698            assert!(!claimed.contains(&id), "claimed a domain that is {why}");
699        }
700    }
701
702    #[tokio::test]
703    #[ignore = "requires PostgreSQL"]
704    async fn provision_requeues_a_failed_domain_and_clears_its_history() {
705        let pool = pool().await;
706        let _exclusive = exclusive().await;
707        let t = tenant(&pool).await;
708        let (id, _) = domain(&pool, t, "failed", 10, Some(60)).await;
709        sqlx::query("UPDATE proxy_domains SET ssl_error = 'something' WHERE id = $1")
710            .bind(id)
711            .execute(&pool)
712            .await
713            .unwrap();
714
715        let requeued = crate::saas::db::queue_for_issuance(&pool, id, t)
716            .await
717            .expect("retry");
718        assert!(requeued.is_some(), "provision should find the domain");
719
720        let row = sqlx::query(
721            "SELECT ssl_status, ssl_attempts, ssl_error, ssl_last_attempt_at \
722             FROM proxy_domains WHERE id = $1",
723        )
724        .bind(id)
725        .fetch_one(&pool)
726        .await
727        .unwrap();
728
729        use sqlx::Row;
730        assert_eq!(row.get::<String, _>("ssl_status"), "pending");
731        assert_eq!(row.get::<i32, _>("ssl_attempts"), 0);
732        assert!(row.get::<Option<String>, _>("ssl_error").is_none());
733        // Cleared, so the next pass picks it up now rather than waiting out a
734        // backoff computed for a cause the operator has just fixed.
735        assert!(row
736            .get::<Option<chrono::DateTime<chrono::Utc>>, _>("ssl_last_attempt_at")
737            .is_none());
738
739        assert!(
740            claims_include(&pool, id).await,
741            "a retried domain must be claimable straight away"
742        );
743    }
744
745    #[tokio::test]
746    #[ignore = "requires PostgreSQL"]
747    async fn a_domain_abandoned_mid_issuance_is_requeued() {
748        let pool = pool().await;
749        let _exclusive = exclusive().await;
750        let t = tenant(&pool).await;
751
752        // Claimed an hour ago and never finished: a restart mid-order, or a
753        // database error on the way to recording the result. Nothing else ever
754        // moves a row out of `provisioning`, so without the reaper this domain
755        // silently never gets a certificate.
756        let (id, _) = domain(&pool, t, "provisioning", 0, Some(3600)).await;
757
758        let recovered = reclaim_abandoned(&pool).await.expect("reclaim");
759        assert_eq!(recovered, 1, "the abandoned domain was not requeued");
760
761        assert!(
762            claims_include(&pool, id).await,
763            "a requeued domain must be claimable again"
764        );
765
766        let attempts: i32 =
767            sqlx::query_scalar("SELECT ssl_attempts FROM proxy_domains WHERE id = $1")
768                .bind(id)
769                .fetch_one(&pool)
770                .await
771                .unwrap();
772        // The interrupted order was still an order. Without counting it, a
773        // proxy crash-looping during issuance would retry without limit.
774        assert_eq!(attempts, 1, "the interrupted attempt was not counted");
775    }
776
777    #[tokio::test]
778    #[ignore = "requires PostgreSQL"]
779    async fn a_live_order_is_not_stolen_from_the_worker_running_it() {
780        let pool = pool().await;
781        let _exclusive = exclusive().await;
782        let t = tenant(&pool).await;
783
784        // Claimed a minute ago: well inside STUCK_AFTER, so another instance
785        // is plausibly still working on it. Reclaiming here would have two
786        // workers ordering the same certificate.
787        let (id, _) = domain(&pool, t, "provisioning", 0, Some(60)).await;
788
789        let recovered = reclaim_abandoned(&pool).await.expect("reclaim");
790        assert_eq!(recovered, 0, "a live order was reclaimed");
791
792        let status: String =
793            sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
794                .bind(id)
795                .fetch_one(&pool)
796                .await
797                .unwrap();
798        assert_eq!(status, "provisioning");
799    }
800
801    #[tokio::test]
802    #[ignore = "requires PostgreSQL"]
803    async fn reclaiming_leaves_other_providers_and_states_alone() {
804        let pool = pool().await;
805        let _exclusive = exclusive().await;
806        let t = tenant(&pool).await;
807
808        let (pending, _) = domain(&pool, t, "pending", 0, Some(3600)).await;
809        let (active, _) = domain(&pool, t, "active", 0, Some(3600)).await;
810
811        // Provisioning, long ago, but not an ACME domain -- this worker has no
812        // business touching it.
813        let manual: Uuid = sqlx::query_scalar(
814            "INSERT INTO proxy_domains \
815                (tenant_id, domain, verification_status, verification_token, \
816                 ssl_status, ssl_provider, ssl_last_attempt_at) \
817             VALUES ($1, $2, 'verified', 'tok', 'provisioning', 'manual', \
818                 NOW() - make_interval(secs => 3600)) RETURNING id",
819        )
820        .bind(t)
821        .bind(format!("manual-{}.example", Uuid::new_v4()))
822        .fetch_one(&pool)
823        .await
824        .unwrap();
825
826        let recovered = reclaim_abandoned(&pool).await.expect("reclaim");
827        assert_eq!(recovered, 0, "reclaimed something it should not have");
828
829        for (id, expected) in [
830            (pending, "pending"),
831            (active, "active"),
832            (manual, "provisioning"),
833        ] {
834            let status: String =
835                sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
836                    .bind(id)
837                    .fetch_one(&pool)
838                    .await
839                    .unwrap();
840            assert_eq!(
841                status, expected,
842                "a row was changed that should not have been"
843            );
844        }
845    }
846
847    #[tokio::test]
848    #[ignore = "requires PostgreSQL"]
849    async fn provision_refuses_a_domain_that_is_not_verified() {
850        let pool = pool().await;
851        let _exclusive = exclusive().await;
852        let t = tenant(&pool).await;
853
854        let id: Uuid = sqlx::query_scalar(
855            "INSERT INTO proxy_domains \
856                (tenant_id, domain, verification_status, verification_token, \
857                 ssl_status, ssl_provider) \
858             VALUES ($1, $2, 'pending', 'tok', 'pending', 'acme') RETURNING id",
859        )
860        .bind(t)
861        .bind(format!("unverified-{}.example", Uuid::new_v4()))
862        .fetch_one(&pool)
863        .await
864        .unwrap();
865
866        // Queueing an unverified domain would have the worker place an order
867        // for a name nobody has proved control of. The CA would refuse it, but
868        // the attempt still spends a rate limit.
869        let queued = crate::saas::db::queue_for_issuance(&pool, id, t)
870            .await
871            .expect("queue");
872        assert!(queued.is_none(), "queued an unverified domain");
873    }
874
875    #[tokio::test]
876    #[ignore = "requires PostgreSQL"]
877    async fn provision_refuses_a_manual_domain() {
878        let pool = pool().await;
879        let _exclusive = exclusive().await;
880        let t = tenant(&pool).await;
881
882        let id: Uuid = sqlx::query_scalar(
883            "INSERT INTO proxy_domains \
884                (tenant_id, domain, verification_status, verification_token, \
885                 ssl_status, ssl_provider) \
886             VALUES ($1, $2, 'verified', 'tok', 'pending', 'manual') RETURNING id",
887        )
888        .bind(t)
889        .bind(format!("manual-{}.example", Uuid::new_v4()))
890        .fetch_one(&pool)
891        .await
892        .unwrap();
893
894        let queued = crate::saas::db::queue_for_issuance(&pool, id, t)
895            .await
896            .expect("queue");
897        assert!(
898            queued.is_none(),
899            "queued a domain whose certificate does not come from ACME"
900        );
901    }
902
903    #[tokio::test]
904    #[ignore = "requires PostgreSQL"]
905    async fn switching_a_verified_domain_to_acme_queues_it() {
906        let pool = pool().await;
907        let _exclusive = exclusive().await;
908        let t = tenant(&pool).await;
909
910        // Verified, manual, and nowhere near the worker's queue.
911        let id: Uuid = sqlx::query_scalar(
912            "INSERT INTO proxy_domains \
913                (tenant_id, domain, verification_status, verification_token, \
914                 ssl_status, ssl_provider, ssl_attempts, ssl_error) \
915             VALUES ($1, $2, 'verified', 'tok', 'active', 'manual', 4, 'old') \
916             RETURNING id",
917        )
918        .bind(t)
919        .bind(format!("switch-{}.example", Uuid::new_v4()))
920        .fetch_one(&pool)
921        .await
922        .unwrap();
923
924        let updated = crate::saas::db::update_domain(
925            &pool,
926            id,
927            t,
928            None,
929            Some(&crate::saas::types::SslProvider::Acme),
930        )
931        .await
932        .expect("update")
933        .expect("domain should be found");
934
935        // Otherwise the domain would sit in whatever state the old provider
936        // left it in, and nothing would ever pick it up.
937        assert_eq!(
938            format!("{:?}", updated.ssl_status),
939            "Pending",
940            "switching to acme must queue the domain"
941        );
942        assert!(
943            claims_include(&pool, id).await,
944            "a domain switched to acme must become claimable"
945        );
946
947        let row = sqlx::query("SELECT ssl_attempts, ssl_error FROM proxy_domains WHERE id = $1")
948            .bind(id)
949            .fetch_one(&pool)
950            .await
951            .unwrap();
952        use sqlx::Row as _;
953        assert_eq!(row.get::<i32, _>("ssl_attempts"), 0, "history not cleared");
954        assert!(row.get::<Option<String>, _>("ssl_error").is_none());
955    }
956
957    #[tokio::test]
958    #[ignore = "requires PostgreSQL"]
959    async fn switching_an_unverified_domain_to_acme_does_not_queue_it() {
960        let pool = pool().await;
961        let _exclusive = exclusive().await;
962        let t = tenant(&pool).await;
963
964        let id: Uuid = sqlx::query_scalar(
965            "INSERT INTO proxy_domains \
966                (tenant_id, domain, verification_status, verification_token, \
967                 ssl_status, ssl_provider) \
968             VALUES ($1, $2, 'pending', 'tok', 'pending', 'manual') RETURNING id",
969        )
970        .bind(t)
971        .bind(format!("switch-unverified-{}.example", Uuid::new_v4()))
972        .fetch_one(&pool)
973        .await
974        .unwrap();
975
976        crate::saas::db::update_domain(
977            &pool,
978            id,
979            t,
980            None,
981            Some(&crate::saas::types::SslProvider::Acme),
982        )
983        .await
984        .expect("update")
985        .expect("domain should be found");
986
987        assert!(
988            !claims_include(&pool, id).await,
989            "an unverified domain must not become claimable by changing its provider"
990        );
991    }
992
993    #[tokio::test]
994    #[ignore = "requires PostgreSQL"]
995    async fn an_update_leaves_absent_fields_alone() {
996        let pool = pool().await;
997        let _exclusive = exclusive().await;
998        let t = tenant(&pool).await;
999        let (id, _) = domain(&pool, t, "pending", 3, Some(10)).await;
1000
1001        // Only the verification method. The provider, and everything the
1002        // provider clause would have reset, must be untouched.
1003        let updated = crate::saas::db::update_domain(
1004            &pool,
1005            id,
1006            t,
1007            Some(&crate::saas::types::VerificationMethod::Http),
1008            None,
1009        )
1010        .await
1011        .expect("update")
1012        .expect("domain should be found");
1013
1014        assert_eq!(format!("{:?}", updated.verification_method), "Http");
1015        assert_eq!(format!("{:?}", updated.ssl_provider), "Acme");
1016
1017        let attempts: i32 =
1018            sqlx::query_scalar("SELECT ssl_attempts FROM proxy_domains WHERE id = $1")
1019                .bind(id)
1020                .fetch_one(&pool)
1021                .await
1022                .unwrap();
1023        assert_eq!(attempts, 3, "an unrelated update reset the attempt count");
1024    }
1025
1026    #[tokio::test]
1027    #[ignore = "requires PostgreSQL"]
1028    async fn an_update_will_not_cross_a_tenant_boundary() {
1029        let pool = pool().await;
1030        let _exclusive = exclusive().await;
1031        let owner = tenant(&pool).await;
1032        let stranger = tenant(&pool).await;
1033        let (id, _) = domain(&pool, owner, "pending", 0, None).await;
1034
1035        let updated = crate::saas::db::update_domain(
1036            &pool,
1037            id,
1038            stranger,
1039            None,
1040            Some(&crate::saas::types::SslProvider::None),
1041        )
1042        .await
1043        .expect("update");
1044        assert!(updated.is_none(), "update crossed a tenant boundary");
1045    }
1046
1047    #[tokio::test]
1048    #[ignore = "requires PostgreSQL"]
1049    async fn provision_will_not_touch_another_tenants_domain() {
1050        let pool = pool().await;
1051        let _exclusive = exclusive().await;
1052        let owner = tenant(&pool).await;
1053        let stranger = tenant(&pool).await;
1054        let (id, _) = domain(&pool, owner, "failed", 10, Some(60)).await;
1055
1056        let requeued = crate::saas::db::queue_for_issuance(&pool, id, stranger)
1057            .await
1058            .expect("retry");
1059        assert!(requeued.is_none(), "provision crossed a tenant boundary");
1060
1061        let status: String =
1062            sqlx::query_scalar("SELECT ssl_status FROM proxy_domains WHERE id = $1")
1063                .bind(id)
1064                .fetch_one(&pool)
1065                .await
1066                .unwrap();
1067        assert_eq!(status, "failed", "another tenant's domain was modified");
1068    }
1069
1070    #[tokio::test]
1071    #[ignore = "requires PostgreSQL"]
1072    async fn two_workers_do_not_claim_the_same_domain() {
1073        let pool = pool().await;
1074        let _exclusive = exclusive().await;
1075        let t = tenant(&pool).await;
1076        let (id, _) = domain(&pool, t, "pending", 0, None).await;
1077
1078        // Claiming flips the status inside the same statement, so a second claim
1079        // cannot see the row. Without that, two instances would each place an
1080        // order and spend two of the CA's rate-limited slots for one certificate.
1081        let first = claim_next(&pool).await.expect("claim");
1082        assert!(first.is_some());
1083
1084        let others = drain(&pool).await;
1085        assert!(!others.contains(&id), "the same domain was claimed twice");
1086    }
1087}