Skip to main content

acme_proxy_store/
http01_token.rs

1//! The `http01_tokens` table: key authorizations the `relay` backend publishes
2//! for its upstream CA to fetch (RFC 8555 §8.3).
3//!
4//! In the database rather than in the backend's memory so the process serving
5//! `/.well-known/acme-challenge/{token}` need not be the one whose relay job
6//! published it. `signer::relay::http01` is the only user.
7
8use tracing::debug;
9
10use crate::db::Database;
11
12/// One published key authorization.
13pub struct Http01Token;
14
15impl Http01Token {
16    /// Makes `key_authorization` servable under `token` until `expires_at`.
17    ///
18    /// An upsert: a re-run of the attempt that published it — after a crash,
19    /// or a retry — publishes the same token again, and the newer deadline is
20    /// the one that should stand.
21    pub async fn publish(
22        token: &str,
23        key_authorization: &str,
24        now: i64,
25        expires_at: i64,
26        database: &Database,
27    ) -> Result<(), sqlx::Error> {
28        crate::sql::query(
29            "INSERT INTO http01_tokens (token, key_authorization, created_at, expires_at) \
30             VALUES (?, ?, ?, ?) \
31             ON CONFLICT (token) DO UPDATE SET \
32             key_authorization = excluded.key_authorization, expires_at = excluded.expires_at;",
33        )
34        .bind(token)
35        .bind(key_authorization)
36        .bind(now)
37        .bind(expires_at)
38        .execute(database)
39        .await?;
40        debug!(event = "db_http_01_token_published", outcome = "success", token = %token, expires_at);
41        Ok(())
42    }
43
44    /// Stops serving `token`. Idempotent.
45    pub async fn retract(token: &str, database: &Database) -> Result<(), sqlx::Error> {
46        let result = crate::sql::query("DELETE FROM http01_tokens WHERE token = ?;")
47            .bind(token)
48            .execute(database)
49            .await?;
50        debug!(
51            event = "db_http_01_token_retracted",
52            outcome = "success",
53            token = %token,
54            rows_removed = result.rows_affected(),
55        );
56        Ok(())
57    }
58
59    /// The key authorization to serve for `token`, unless it has expired.
60    pub async fn lookup(
61        token: &str,
62        now: i64,
63        database: &Database,
64    ) -> Result<Option<String>, sqlx::Error> {
65        crate::sql::query(
66            "SELECT key_authorization FROM http01_tokens WHERE token = ? AND expires_at > ?;",
67        )
68        .bind(token)
69        .bind(now)
70        .fetch_optional(database)
71        .await?
72        .map(|row| row.try_get("key_authorization"))
73        .transpose()
74    }
75
76    /// Deletes every token at or past its `expires_at`, returning how many.
77    pub async fn cleanup(now: i64, database: &Database) -> Result<u64, sqlx::Error> {
78        let result = crate::sql::query("DELETE FROM http01_tokens WHERE expires_at <= ?;")
79            .bind(now)
80            .execute(database)
81            .await?;
82        // Debug: `jobs::sweep` already reports the pass that called this, and
83        // an hourly line saying nothing expired is noise in an operator's log.
84        debug!(
85            event = "db_http_01_token_cleanup_completed",
86            outcome = "success",
87            rows_removed = result.rows_affected(),
88            cutoff = now,
89        );
90        Ok(result.rows_affected())
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[tokio::test]
99    async fn a_published_token_is_looked_up_until_retracted() {
100        let database = Database::connect_for_test().await.unwrap();
101        assert_eq!(
102            Http01Token::lookup("tok", 10, &database).await.unwrap(),
103            None
104        );
105
106        Http01Token::publish("tok", "tok.one", 10, 100, &database)
107            .await
108            .unwrap();
109        assert_eq!(
110            Http01Token::lookup("tok", 10, &database).await.unwrap(),
111            Some("tok.one".to_string())
112        );
113
114        Http01Token::retract("tok", &database).await.unwrap();
115        Http01Token::retract("tok", &database).await.unwrap();
116        assert_eq!(
117            Http01Token::lookup("tok", 10, &database).await.unwrap(),
118            None
119        );
120    }
121
122    /// A re-publish replaces the key authorization and moves the deadline on.
123    #[tokio::test]
124    async fn publishing_again_replaces_the_row() {
125        let database = Database::connect_for_test().await.unwrap();
126        Http01Token::publish("tok", "tok.one", 10, 20, &database)
127            .await
128            .unwrap();
129        Http01Token::publish("tok", "tok.two", 15, 200, &database)
130            .await
131            .unwrap();
132
133        assert_eq!(
134            Http01Token::lookup("tok", 100, &database).await.unwrap(),
135            Some("tok.two".to_string())
136        );
137    }
138
139    /// An expired row is never served, and the sweep takes exactly those.
140    #[tokio::test]
141    async fn an_expired_token_is_not_served_and_is_swept() {
142        let database = Database::connect_for_test().await.unwrap();
143        Http01Token::publish("old", "old.ka", 0, 50, &database)
144            .await
145            .unwrap();
146        Http01Token::publish("live", "live.ka", 0, 500, &database)
147            .await
148            .unwrap();
149
150        assert_eq!(
151            Http01Token::lookup("old", 50, &database).await.unwrap(),
152            None
153        );
154        assert_eq!(Http01Token::cleanup(50, &database).await.unwrap(), 1);
155        assert_eq!(
156            Http01Token::lookup("live", 50, &database).await.unwrap(),
157            Some("live.ka".to_string())
158        );
159    }
160}