postrust-proxy 0.5.1

Reverse proxy for Postrust with load balancing and automatic TLS certificates
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
//! ACME certificate issuance over HTTP-01.
//!
//! One certificate at a time, for one domain, on demand. That shape is what the
//! multi-tenant case needs: domains arrive and leave while the proxy runs, so
//! the set cannot be known at startup.
//!
//! This replaced a `rustls-acme` wrapper that took a fixed domain list and
//! handed back a `ServerConfig` whose resolver answered challenges inside the
//! TLS handshake. That model cannot serve a domain it was not started with, and
//! the wrapper was never constructed anywhere — so rather than keep two ACME
//! clients in one crate (which also resolved two copies of `rcgen`), it is
//! gone.
//!
//! # The flow
//!
//! 1. Load the account from `proxy_acme_accounts`, or register one and store it.
//! 2. Place an order for the domain.
//! 3. For each authorization, take the `http-01` challenge and write its token
//!    and key authorization to `proxy_acme_challenges`, where
//!    [`crate::saas::handlers::wellknown::handle_acme_challenge`] can find them.
//! 4. Tell the CA the challenge is ready, and poll until the order is.
//! 5. Finalize — which builds the CSR and returns a fresh private key — then
//!    poll for the certificate chain.
//! 6. Store both in the certificate store and delete the challenge rows.
//!
//! # Why HTTP-01 and not DNS-01
//!
//! HTTP-01 needs only that the domain resolves to this proxy, which is already
//! true for any domain it is serving. DNS-01 would need write access to each
//! tenant's zone, which a proxy has no way to obtain. The trade is that HTTP-01
//! cannot issue wildcards.

use std::sync::Arc;
use std::time::Duration;

use instant_acme::{
    Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, NewAccount,
    NewOrder, RetryPolicy,
};
use sqlx::{PgPool, Row};

use crate::error::{ProxyError, ProxyResult};
use crate::tls::cert_store::{Certificate, CertificateStore};

/// Let's Encrypt's production directory.
pub const LETS_ENCRYPT_PRODUCTION: &str = "https://acme-v02.api.letsencrypt.org/directory";
/// Let's Encrypt's staging directory, which issues untrusted certificates
/// against far looser rate limits.
pub const LETS_ENCRYPT_STAGING: &str = "https://acme-staging-v02.api.letsencrypt.org/directory";

/// How long to keep polling one order before giving up.
///
/// Pebble, and Let's Encrypt under load, can leave an order pending for tens of
/// seconds. The worker retries with backoff, so a timeout here is a delay
/// rather than a failure.
const ORDER_TIMEOUT: Duration = Duration::from_secs(60);

/// The lifetime assumed when a chain's own expiry cannot be read.
///
/// Short on purpose. Renewal falls due 30 days before this, so a certificate
/// with an unreadable expiry is replaced after about a month rather than left
/// until it stops working.
const FALLBACK_LIFETIME: chrono::TimeDelta = chrono::TimeDelta::days(60);

/// Issues certificates for individual domains over ACME HTTP-01.
pub struct AcmeIssuer {
    directory_url: String,
    contact: Option<String>,
    /// A PEM root to trust for the directory itself.
    ///
    /// Only for testing against a CA with a private root -- Pebble serves its
    /// directory over a self-signed chain. `None` uses the platform roots.
    root_pem_path: Option<std::path::PathBuf>,
    pool: PgPool,
    cert_store: Arc<CertificateStore>,
}

impl AcmeIssuer {
    /// Create an issuer against a directory URL.
    pub fn new(
        directory_url: impl Into<String>,
        contact: Option<String>,
        pool: PgPool,
        cert_store: Arc<CertificateStore>,
    ) -> Self {
        Self {
            directory_url: directory_url.into(),
            contact,
            root_pem_path: None,
            pool,
            cert_store,
        }
    }

    /// Trust a private root for the ACME directory's own TLS.
    ///
    /// For testing against Pebble, which serves its directory with a
    /// self-signed chain. Never needed against a public CA.
    pub fn with_root_certificate(mut self, pem_path: impl Into<std::path::PathBuf>) -> Self {
        self.root_pem_path = Some(pem_path.into());
        self
    }

    /// The directory this issuer talks to.
    pub fn directory_url(&self) -> &str {
        &self.directory_url
    }

    /// Obtain a certificate for `domain` and store it.
    ///
    /// Returns the stored certificate. Any challenge rows this created are
    /// removed before returning, on the failure path too -- a token left
    /// answerable after its order is gone is a response the proxy will hand out
    /// to anyone who asks for it.
    pub async fn issue(&self, domain: &str) -> ProxyResult<Certificate> {
        let account = self.account().await?;

        let identifiers = [Identifier::Dns(domain.to_owned())];
        let mut order = account
            .new_order(&NewOrder::new(&identifiers))
            .await
            .map_err(|e| ProxyError::Acme(format!("could not place an order for {domain}: {e}")))?;

        let result = self.satisfy_and_finalize(&mut order, domain).await;

        // Whatever happened, the tokens must stop being answerable.
        if let Err(cleanup) = self.clear_challenges(domain).await {
            tracing::warn!(%domain, error = %cleanup, "could not clear ACME challenge rows");
        }

        let (cert_pem, key_pem) = result?;

        // An expiry we cannot read must not mean "never renew".
        //
        // The renewal scan selects on `expires_at IS NOT NULL AND expires_at <
        // deadline`, so storing `None` here would leave the certificate to
        // expire with nothing scheduled to replace it -- silently, months
        // later. Discarding a certificate the CA has already issued is worse
        // (the rate limit is spent either way, and the domain would have no
        // certificate at all), so it is stored with a deliberately short
        // assumed lifetime instead: renewal then falls due at
        // `FALLBACK_LIFETIME - RENEW_WITHIN`, comfortably inside the 90 days a
        // public CA issues for.
        let expires_at = match crate::tls::expiry_of(cert_pem.as_bytes()) {
            Some(expiry) => expiry,
            None => {
                let assumed = chrono::Utc::now() + FALLBACK_LIFETIME;
                tracing::error!(
                    %domain,
                    assumed_expiry = %assumed,
                    "could not read the expiry from the issued chain; \
                     assuming a short lifetime so renewal is still scheduled"
                );
                assumed
            }
        };

        let certificate = Certificate {
            domain: domain.to_owned(),
            expires_at: Some(expires_at),
            cert_pem: cert_pem.into_bytes(),
            key_pem: key_pem.into_bytes(),
        };
        self.cert_store.save(certificate.clone()).await?;

        tracing::info!(
            %domain,
            expires_at = ?certificate.expires_at,
            "issued an ACME certificate"
        );
        Ok(certificate)
    }

    /// Answer every authorization, finalize, and fetch the chain.
    ///
    /// Returns `(certificate_chain_pem, private_key_pem)`.
    async fn satisfy_and_finalize(
        &self,
        order: &mut instant_acme::Order,
        domain: &str,
    ) -> ProxyResult<(String, String)> {
        let mut authorizations = order.authorizations();
        while let Some(result) = authorizations.next().await {
            let mut authz = result
                .map_err(|e| ProxyError::Acme(format!("could not read an authorization: {e}")))?;

            match authz.status {
                // Already satisfied, from an earlier order for the same domain.
                AuthorizationStatus::Valid => continue,
                AuthorizationStatus::Pending => {}
                other => {
                    return Err(ProxyError::Acme(format!(
                        "authorization for {domain} is {other:?}, which cannot be answered"
                    )))
                }
            }

            let mut challenge = authz.challenge(ChallengeType::Http01).ok_or_else(|| {
                ProxyError::Acme(format!(
                    "the CA offered no http-01 challenge for {domain}; \
                     only http-01 is implemented"
                ))
            })?;

            // Store the response *before* telling the CA to look for it. The
            // other order loses the race: validation can arrive immediately.
            let token = challenge.token.clone();
            let key_authorization = challenge.key_authorization().as_str().to_owned();
            self.store_challenge(&token, domain, &key_authorization)
                .await?;

            challenge.set_ready().await.map_err(|e| {
                ProxyError::Acme(format!("the CA rejected our challenge for {domain}: {e}"))
            })?;
        }

        let retry = RetryPolicy::default().timeout(ORDER_TIMEOUT);

        order.poll_ready(&retry).await.map_err(|e| {
            ProxyError::Acme(format!(
                "the order for {domain} did not become ready: {e}. \
                 The usual cause is that {domain} does not resolve to this proxy, \
                 so the CA could not fetch the challenge."
            ))
        })?;

        let key_pem = order
            .finalize()
            .await
            .map_err(|e| ProxyError::Acme(format!("could not finalize {domain}: {e}")))?;

        let cert_pem = order
            .poll_certificate(&retry)
            .await
            .map_err(|e| ProxyError::Acme(format!("no certificate came back for {domain}: {e}")))?;

        Ok((cert_pem, key_pem))
    }

    /// Load the stored account, or register one and store it.
    ///
    /// Registering is rate-limited -- Let's Encrypt allows 10 per IP per three
    /// hours -- so this must not happen per issuance. Keyed by directory URL so
    /// moving between staging and production does not reuse an account the
    /// other CA has never heard of.
    async fn account(&self) -> ProxyResult<Account> {
        if let Some(credentials) = self.load_credentials().await? {
            return self
                .builder()?
                .from_credentials(credentials)
                .await
                .map_err(|e| {
                    ProxyError::Acme(format!("stored ACME credentials are unusable: {e}"))
                });
        }

        let contact = self.contact.as_ref().map(|email| format!("mailto:{email}"));
        let contact_refs: Vec<&str> = contact.iter().map(String::as_str).collect();

        let (account, credentials) = self
            .builder()?
            .create(
                &NewAccount {
                    contact: &contact_refs,
                    terms_of_service_agreed: true,
                    only_return_existing: false,
                },
                self.directory_url.clone(),
                None,
            )
            .await
            .map_err(|e| {
                ProxyError::Acme(format!(
                    "could not register an ACME account at {}: {e}",
                    self.directory_url
                ))
            })?;

        self.save_credentials(&credentials).await?;
        tracing::info!(directory = %self.directory_url, "registered a new ACME account");
        Ok(account)
    }

    fn builder(&self) -> ProxyResult<instant_acme::AccountBuilder> {
        // instant-acme talks to the directory over rustls, and rustls refuses
        // to guess a provider when both aws-lc-rs and ring are reachable in the
        // tree -- it panics on first use instead. The TLS listener installs one
        // for the same reason; an ACME order can happen without any listener
        // having been built, so it has to be done here as well. Idempotent.
        crate::tls::server::install_crypto_provider();

        match &self.root_pem_path {
            Some(path) => Account::builder_with_root(path).map_err(|e| {
                ProxyError::Acme(format!(
                    "could not use {} as an ACME root: {e}",
                    path.display()
                ))
            }),
            None => Account::builder()
                .map_err(|e| ProxyError::Acme(format!("could not build an ACME client: {e}"))),
        }
    }

    async fn load_credentials(&self) -> ProxyResult<Option<AccountCredentials>> {
        let row =
            sqlx::query("SELECT credentials FROM proxy_acme_accounts WHERE directory_url = $1")
                .bind(&self.directory_url)
                .fetch_optional(&self.pool)
                .await?;

        let Some(row) = row else { return Ok(None) };
        let value: serde_json::Value = row.try_get("credentials")?;
        serde_json::from_value(value)
            .map(Some)
            .map_err(|e| ProxyError::Acme(format!("stored ACME credentials do not parse: {e}")))
    }

    async fn save_credentials(&self, credentials: &AccountCredentials) -> ProxyResult<()> {
        let value = serde_json::to_value(credentials)
            .map_err(|e| ProxyError::Acme(format!("could not serialize ACME credentials: {e}")))?;

        // ON CONFLICT DO NOTHING, not DO UPDATE: if two workers registered at
        // once, the first account stored is the one whose authorizations the CA
        // already knows about. Overwriting it would discard them.
        sqlx::query(
            "INSERT INTO proxy_acme_accounts (directory_url, credentials) \
             VALUES ($1, $2) ON CONFLICT (directory_url) DO NOTHING",
        )
        .bind(&self.directory_url)
        .bind(value)
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn store_challenge(
        &self,
        token: &str,
        domain: &str,
        key_authorization: &str,
    ) -> ProxyResult<()> {
        sqlx::query(
            "INSERT INTO proxy_acme_challenges (token, domain, key_authorization) \
             VALUES ($1, $2, $3) \
             ON CONFLICT (token) DO UPDATE SET \
                 domain = EXCLUDED.domain, \
                 key_authorization = EXCLUDED.key_authorization, \
                 expires_at = NOW() + INTERVAL '1 hour'",
        )
        .bind(token)
        .bind(domain)
        .bind(key_authorization)
        .execute(&self.pool)
        .await?;
        Ok(())
    }

    async fn clear_challenges(&self, domain: &str) -> ProxyResult<()> {
        sqlx::query("DELETE FROM proxy_acme_challenges WHERE domain = $1 OR expires_at < NOW()")
            .bind(domain)
            .execute(&self.pool)
            .await?;
        Ok(())
    }
}

/// A live HTTP-01 challenge response.
pub struct AcmeChallengeResponse {
    /// The domain the challenge was issued for.
    pub domain: String,
    /// The body to return, verbatim.
    pub key_authorization: String,
}

/// Look up a pending HTTP-01 challenge response by token.
///
/// Free function rather than a method because the endpoint that serves
/// challenges has a pool but no reason to hold an issuer.
pub async fn find_challenge(
    pool: &PgPool,
    token: &str,
) -> ProxyResult<Option<AcmeChallengeResponse>> {
    let row = sqlx::query(
        "SELECT domain, key_authorization FROM proxy_acme_challenges \
         WHERE token = $1 AND expires_at > NOW()",
    )
    .bind(token)
    .fetch_optional(pool)
    .await?;

    Ok(row.map(|row| AcmeChallengeResponse {
        domain: row.get("domain"),
        key_authorization: row.get("key_authorization"),
    }))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn known_directories_are_https_urls() {
        for url in [LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING] {
            assert!(url.starts_with("https://"), "{url}");
            assert!(url.ends_with("/directory"), "{url}");
        }
        assert_ne!(LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING);
    }
}