Skip to main content

edgeguard/
acme.rs

1//! ACME (Let's Encrypt) automatic certificates via the HTTP-01 challenge, using
2//! `instant-acme` for the protocol; it generates the key and CSR in `finalize()`.
3//!
4//! Flow: create/restore an ACME account → open an order for the configured domains → answer
5//! each domain's HTTP-01 challenge from a tiny listener on port 80 → finalize with a freshly
6//! generated key + CSR → write the issued chain and key to [`TlsCfg::cert_path`] /
7//! [`TlsCfg::key_path`], which the TLS listener then loads.
8//!
9//! # Proven working, 2026-08-23 (instant-acme 0.8)
10//!
11//! `acme_http01_issues_against_pebble` passes against Pebble, a real ACME CA. It is the first
12//! time it ever has. It was blocked by four separate things, none of them this module's logic:
13//!
14//! 1. the test never installed a rustls `CryptoProvider`, so it panicked before any ACME ran;
15//! 2. `instant-acme` 0.7.2 (Oct 2024) could not parse the CA's authorization payload —
16//!    `` `missing field `token` `` `` — which is what broke issuance in the field;
17//! 3. 0.7 verified against compiled-in webpki-roots, so no private test CA could be trusted.
18//!    0.8 uses the platform trust store, so installing Pebble's root now works;
19//! 4. the test rig itself: the wrong Pebble root, the wrong challtestsrv flag, and AAAA
20//!    answers pointing validation at ::1. See `loadtest/pebble.compose.yaml`.
21//!
22//! NOTE: this path talks to a live ACME CA and binds port 80, so it is not exercised by the
23//! *default* suite (no domain, no inbound :80). A `#[ignore]`d end-to-end test
24//! (`acme_http01_issues_against_pebble`) is written against **Pebble** (a tiny test ACME CA) —
25//! see the test for the setup and `loadtest/pebble.compose.yaml`. It **passes** against Pebble
26//! (0.8 verifies with the platform trust store, so a private test CA can now be installed — see
27//! the four blockers above and `docs/ACME_TESTING.md`); it stays `#[ignore]`d only because it
28//! needs a live CA and inbound port 80, which the default suite has neither of.
29//! The default directory is Let's Encrypt **staging** (see `AcmeCfg::directory_url`) precisely so
30//! a first run can't burn production rate limits.
31
32use std::collections::HashMap;
33use std::path::Path;
34use std::sync::{Arc, RwLock};
35
36use anyhow::{Context, Result};
37use axum::{
38    extract::{Path as AxPath, State},
39    http::StatusCode,
40    routing::get,
41    Router,
42};
43use instant_acme::{
44    Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, NewAccount,
45    NewOrder, OrderStatus, RetryPolicy,
46};
47use tokio::net::TcpListener;
48use tracing::{info, warn};
49
50use crate::config::{AcmeCfg, TlsCfg};
51
52/// The TCP port the ACME CA connects to for an HTTP-01 challenge. Fixed by RFC 8555 §8.3.
53const HTTP01_PORT: u16 = 80;
54
55/// Which set of books refused an order. The remedies are different, so the distinction is worth
56/// carrying all the way to the operator's log line.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum DeferSource {
59    /// The **fleet's** shared budget, held by the control plane. Some other edge under the same
60    /// registered domain spent the allowance; nothing about this box will change that, and waiting
61    /// (or reducing how often the fleet re-orders) is the only remedy.
62    Fleet,
63    /// This edge's **own** ledger. Usually a certificate cache that is not durable, so every
64    /// restart re-orders — fixable here, by making the ACME cache directory survive a restart.
65    Local,
66}
67
68impl DeferSource {
69    pub fn label(&self) -> &'static str {
70        match self {
71            DeferSource::Fleet => "fleet",
72            DeferSource::Local => "local",
73        }
74    }
75}
76
77/// The outcome of an issuance attempt.
78#[derive(Debug)]
79pub enum Issuance {
80    /// A certificate and key are on disk.
81    Issued,
82    /// The CA's rate limit for `bucket` is spent, so the order was not sent. `retry_at_unix` is when
83    /// that bucket next admits.
84    ///
85    /// Deliberately not an `Err`: the caller must be able to tell "we chose not to ask" from "the
86    /// order failed", because the correct response differs. A failure propagates; a deferral keeps
87    /// serving whatever certificate is already on disk.
88    Deferred {
89        /// The CA limit that refused: `orders` | `registered_domain` | `identifier_set`. A string
90        /// rather than the local enum because the refusal may have come from the control plane,
91        /// whose bucket set is its own — and an edge must not fail to report a deferral because it
92        /// did not recognise a word.
93        bucket: String,
94        /// The bucket key, when the refusal names one (the control plane always does). Says *which*
95        /// registered domain or identifier set is exhausted, which is the difference between an
96        /// actionable log line and a shrug.
97        key: String,
98        retry_at_unix: i64,
99        source: DeferSource,
100    },
101}
102
103/// Decide whether to send this order, against the fleet's books first and this edge's own second.
104///
105/// # Two tiers, and why both
106///
107/// The local ledger (`acme_budget`) covers what one edge can see: the per-identifier-set limit, the
108/// one a restart loop burns. It **cannot** cover the CA's per-registered-domain limit, which is
109/// shared — fifty edges under `example.com` each hold a private ledger, each correctly believe they
110/// have the full allowance, and between them they spend it. Only something all of them talk to can
111/// count that, which is the control plane.
112///
113/// So in managed mode the control plane decides, and holds the fleet's books. Unmanaged, or when
114/// the control plane cannot answer, the local ledger decides.
115///
116/// # A control plane that is down must not stop certificate issuance
117///
118/// Any failure reaching the control plane — unreachable, timing out, 5xx, a control plane too old
119/// to have the endpoint — falls through to the local budget. That degrades fleet-wide coordination
120/// to per-edge coordination, which is exactly where things stood before leases existed. Failing
121/// closed instead would turn a control-plane outage into fleet-wide certificate expiry, a far worse
122/// failure than the one being guarded against.
123///
124/// # Only one set of books is debited
125///
126/// A granted lease means the fleet already debited its buckets, so the local ledger is deliberately
127/// **not** also debited: doing both would count one order twice and exhaust the local guard at a
128/// fifth of the real rate. The local ledger is the fallback authority, not a second toll booth.
129///
130/// `Err(Issuance::Deferred)` is the refusal path — the caller returns it unchanged. `Ok` carries the
131/// local ledger to debit (when it is the deciding authority) and the granted lease id (when the
132/// fleet is).
133type BudgetCheck = (Option<crate::acme_budget::IssuanceBudget>, Option<String>);
134
135async fn check_budget(
136    acme: &AcmeCfg,
137    cp: Option<&crate::cp::CpClient>,
138) -> std::result::Result<BudgetCheck, Issuance> {
139    use crate::cp::LeaseVerdict;
140
141    // NOTE the ordering: the fleet lease is taken BEFORE `budget_enabled` is consulted.
142    //
143    // `budget_enabled` is a per-edge switch over a per-edge ledger, and the fleet's books are not
144    // this edge's to opt out of. One edge setting it false could otherwise spend the shared
145    // registered-domain allowance and leave every other edge under that domain unable to renew —
146    // which is precisely the failure this whole mechanism exists to prevent, re-introduced through
147    // a config flag. So the flag disables the LOCAL ledger below; it does not buy an exemption from
148    // a limit the CA applies to everyone.
149    if let Some(client) = cp {
150        match client.acme_lease(&acme.directory_url, &acme.domains).await {
151            Ok(LeaseVerdict::Granted { lease_id }) => {
152                info!(
153                    lease_id,
154                    domains = ?acme.domains,
155                    "ACME issuance leased from the control plane (fleet-wide budget)"
156                );
157                return Ok((None, Some(lease_id)));
158            }
159            Ok(LeaseVerdict::Deferred {
160                bucket,
161                key,
162                retry_at_unix,
163            }) => {
164                warn!(
165                    source = DeferSource::Fleet.label(),
166                    bucket = %bucket,
167                    key = %key,
168                    retry_at_unix,
169                    domains = ?acme.domains,
170                    "ACME issuance deferred: the FLEET's budget for this CA limit is exhausted — \
171                     another edge under the same key has spent it. The existing certificate (if \
172                     any) keeps serving; no self-signed certificate is substituted on a public \
173                     name."
174                );
175                return Err(Issuance::Deferred {
176                    bucket,
177                    key,
178                    retry_at_unix,
179                    source: DeferSource::Fleet,
180                });
181            }
182            // The control plane keeps no books for this CA, so there is nothing fleet-wide to
183            // apply and the local ledger below is the only guard there is.
184            Ok(LeaseVerdict::Unmanaged) => {}
185            Err(e) => {
186                // Logged at warn, not error: issuance still proceeds under the local budget. It is
187                // worth seeing because while this is happening the fleet-wide limit is unguarded.
188                warn!(
189                    error = %e,
190                    "could not obtain a fleet ACME lease; falling back to this edge's local budget. \
191                     The CA's per-registered-domain limit is uncoordinated until the control plane \
192                     is reachable again."
193                );
194            }
195        }
196    }
197
198    // Past here is the local ledger, and this is what `budget_enabled = false` actually turns off.
199    if !acme.budget_enabled {
200        return Ok((None, None));
201    }
202
203    let mut budget = crate::acme_budget::IssuanceBudget::load(&acme.directory_url, &acme.cache_dir);
204    if let Some(b) = &budget {
205        let now = crate::acme_budget::now_unix();
206        if let crate::acme_budget::Decision::Defer {
207            bucket,
208            retry_at_unix,
209        } = b.check(&acme.domains, now)
210        {
211            crate::acme_budget::warn_deferred(bucket, retry_at_unix, &acme.domains);
212            return Err(Issuance::Deferred {
213                bucket: bucket.label().to_string(),
214                key: String::new(),
215                retry_at_unix,
216                source: DeferSource::Local,
217            });
218        }
219    }
220    // Debit BEFORE the order is sent, and never refund. An order that reaches the CA may have been
221    // counted by it even when the response never arrives, so debiting on success would let a
222    // failing loop spend the real allowance while the local ledger showed it untouched — the exact
223    // situation the budget exists to prevent.
224    if let Some(b) = &mut budget {
225        if let Err(e) = b.debit(&acme.domains, crate::acme_budget::now_unix()) {
226            // A ledger we cannot persist is a budget that resets on restart, which is no budget.
227            // Loud, and not fatal: refusing to serve because a bookkeeping file is unwritable would
228            // be a worse outage than the one being guarded against.
229            warn!(error = %e, "could not persist the ACME issuance ledger; the budget will not survive a restart");
230        }
231    }
232    Ok((budget, None))
233}
234
235/// Obtain (or renew) a certificate for the configured domains and write it to the TLS
236/// cert/key paths. Returns once the certificate chain and key are on disk.
237///
238/// `cp` is the managed-mode control-plane client, when the edge has one. Its presence changes which
239/// books decide: see [`check_budget`].
240pub async fn obtain_certificate(
241    acme: &AcmeCfg,
242    tls: &TlsCfg,
243    cp: Option<&crate::cp::CpClient>,
244) -> Result<Issuance> {
245    anyhow::ensure!(
246        !acme.domains.is_empty(),
247        "tls.acme.domains must list at least one domain"
248    );
249    anyhow::ensure!(
250        acme.accept_tos,
251        "set tls.acme.accept_tos = true to accept the ACME provider's Terms of Service"
252    );
253    anyhow::ensure!(
254        !tls.cert_path.is_empty() && !tls.key_path.is_empty(),
255        "tls.cert_path and tls.key_path must be set so the issued certificate can be stored"
256    );
257
258    // Ask the budget before asking the CA. A CA that refuses an order still counts it, so the only
259    // place this check is worth anything is before the request leaves.
260    let (budget, lease) = match check_budget(acme, cp).await {
261        Ok(ok) => ok,
262        Err(deferred) => return Ok(deferred),
263    };
264
265    let result = run_order(acme, tls, budget.as_ref()).await;
266
267    // Tell the control plane how the leased order ended, on EVERY path out of `run_order`.
268    //
269    // It settles nothing about the budget — the lease was debited at grant and is never refunded —
270    // but it is what turns an exhausted bucket from a bare number into "these forty orders were
271    // granted and only thirty-one produced a certificate", which is how an operator sees an edge
272    // burning fleet budget on orders that keep failing. An unreported lease is closed as consumed
273    // by the control plane once it expires, so a lost report costs observability, not correctness.
274    if let (Some(client), Some(lease_id)) = (cp, lease.as_deref()) {
275        let outcome = if result.is_ok() { "issued" } else { "failed" };
276        client.acme_lease_outcome(lease_id, outcome).await;
277    }
278    result?;
279
280    Ok(Issuance::Issued)
281}
282
283/// The ACME order itself: account, authorizations, challenges, finalize, and writing the pair to
284/// disk. Split out of [`obtain_certificate`] purely so that every failure path is a single `?` the
285/// caller can observe — the lease outcome has to be reported whether this succeeds or not, and a
286/// dozen inline `?`s would each have needed their own reporting.
287async fn run_order(
288    acme: &AcmeCfg,
289    tls: &TlsCfg,
290    budget: Option<&crate::acme_budget::IssuanceBudget>,
291) -> Result<()> {
292    info!(domains = ?acme.domains, directory = %acme.directory_url, "starting ACME order");
293
294    let account = account(acme).await?;
295
296    let identifiers: Vec<Identifier> = acme
297        .domains
298        .iter()
299        .map(|d| Identifier::Dns(d.clone()))
300        .collect();
301    let mut order = account
302        .new_order(&NewOrder::new(&identifiers))
303        .await
304        .context("creating ACME order")?;
305
306    // The challenge server starts BEFORE the authorizations are walked, sharing a map the loop
307    // fills in. In 0.8 a challenge is marked ready through a handle that only exists inside the
308    // iteration, so responses cannot all be gathered first and served afterwards. Publishing
309    // each response *then* signalling ready also closes a window the old two-pass version had,
310    // where the CA could validate a token that was not being served yet.
311    let responses: Arc<RwLock<HashMap<String, String>>> = Arc::new(RwLock::new(HashMap::new()));
312    let _server = AbortOnDrop(spawn_challenge_server(Arc::clone(&responses)).await?);
313
314    let mut authorizations = order.authorizations();
315    while let Some(result) = authorizations.next().await {
316        let mut authz = result.context("fetching authorizations")?;
317        match authz.status {
318            AuthorizationStatus::Pending => {}
319            AuthorizationStatus::Valid => continue,
320            other => anyhow::bail!("unexpected authorization status: {other:?}"),
321        }
322        let mut challenge = authz
323            .challenge(ChallengeType::Http01)
324            .context("CA offered no http-01 challenge")?;
325        // `ChallengeHandle` derefs to `Challenge`, so the token is still readable here.
326        let token = challenge.token.clone();
327        let key_auth = challenge.key_authorization().as_str().to_string();
328        responses
329            .write()
330            .expect("challenge response map poisoned")
331            .insert(token, key_auth);
332        challenge
333            .set_ready()
334            .await
335            .context("signaling challenge ready")?;
336    }
337
338    let status = order
339        .poll_ready(&RetryPolicy::default())
340        .await
341        .context("waiting for the ACME order to become ready")?;
342    anyhow::ensure!(
343        status == OrderStatus::Ready,
344        "ACME order did not become ready (status: {status:?})"
345    );
346
347    // 0.8 generates the key pair and CSR itself and hands back the private key, so there is no
348    // rcgen step here any more.
349    let key_pem = order.finalize().await.context("finalizing ACME order")?;
350    let cert_chain_pem = order
351        .poll_certificate(&RetryPolicy::default())
352        .await
353        .context("waiting for the issued certificate")?;
354
355    write_pem(&tls.cert_path, &cert_chain_pem)?;
356    write_key_pem(&tls.key_path, &key_pem)?;
357    info!(cert = %tls.cert_path, key = %tls.key_path, "ACME certificate stored");
358    if let Some(b) = budget {
359        for (bucket, key, left) in b.remaining(&acme.domains, crate::acme_budget::now_unix()) {
360            info!(
361                ca = b.ca_name(),
362                bucket = bucket.label(),
363                key = %key,
364                remaining = left,
365                "ACME issuance budget after this order"
366            );
367        }
368    }
369    Ok(())
370}
371
372/// Restore the ACME account from cached credentials, or create and cache a new one (so renewals
373/// reuse the same account instead of re-registering).
374async fn account(acme: &AcmeCfg) -> Result<Account> {
375    let creds_path = Path::new(&acme.cache_dir).join("account.json");
376    if creds_path.exists() {
377        let raw = std::fs::read_to_string(&creds_path)
378            .with_context(|| format!("reading cached ACME account {}", creds_path.display()))?;
379        let creds: AccountCredentials =
380            serde_json::from_str(&raw).context("parsing cached ACME account credentials")?;
381        return Account::builder()
382            .context("building ACME client")?
383            .from_credentials(creds)
384            .await
385            .context("restoring ACME account from cached credentials");
386    }
387
388    let mailto = (!acme.email.is_empty()).then(|| format!("mailto:{}", acme.email));
389    let contact: Vec<&str> = mailto.as_deref().into_iter().collect();
390    let (account, credentials) = Account::builder()
391        .context("building ACME client")?
392        .create(
393            &NewAccount {
394                contact: &contact,
395                terms_of_service_agreed: acme.accept_tos,
396                only_return_existing: false,
397            },
398            acme.directory_url.clone(),
399            None,
400        )
401        .await
402        .context("creating ACME account")?;
403
404    if let Err(e) = std::fs::create_dir_all(&acme.cache_dir)
405        .and_then(|_| serde_json::to_string_pretty(&credentials).map_err(std::io::Error::other))
406        .and_then(|json| std::fs::write(&creds_path, json))
407    {
408        warn!(error = %e, path = %creds_path.display(), "could not cache ACME account credentials");
409    }
410    Ok(account)
411}
412
413/// Start a minimal HTTP-01 responder on `:80` serving `token -> key authorization`.
414async fn spawn_challenge_server(
415    responses: Arc<RwLock<HashMap<String, String>>>,
416) -> Result<tokio::task::JoinHandle<()>> {
417    let app = Router::new()
418        .route("/.well-known/acme-challenge/:token", get(challenge_handler))
419        .with_state(responses);
420    let listener = TcpListener::bind(("0.0.0.0", HTTP01_PORT))
421        .await
422        .with_context(|| format!("binding ACME HTTP-01 listener on :{HTTP01_PORT}"))?;
423    Ok(tokio::spawn(async move {
424        if let Err(e) = axum::serve(listener, app).await {
425            warn!(error = %e, "ACME challenge server stopped");
426        }
427    }))
428}
429
430async fn challenge_handler(
431    State(responses): State<Arc<RwLock<HashMap<String, String>>>>,
432    AxPath(token): AxPath<String>,
433) -> (StatusCode, String) {
434    // The map is filled in as each authorization is walked, so this reads under a
435    // lock rather than from a snapshot taken before the order started.
436    let found = responses.read().ok().and_then(|m| m.get(&token).cloned());
437    match found {
438        Some(key_auth) => (StatusCode::OK, key_auth),
439        None => (StatusCode::NOT_FOUND, String::new()),
440    }
441}
442
443fn create_parent(path: &str) -> Result<()> {
444    if let Some(parent) = Path::new(path)
445        .parent()
446        .filter(|p| !p.as_os_str().is_empty())
447    {
448        std::fs::create_dir_all(parent)
449            .with_context(|| format!("creating directory for {path}"))?;
450    }
451    Ok(())
452}
453
454fn write_pem(path: &str, contents: &str) -> Result<()> {
455    create_parent(path)?;
456    std::fs::write(path, contents).with_context(|| format!("writing {path}"))
457}
458
459/// Write the private key with owner-only permissions (`0600` on Unix) rather than inheriting
460/// the process umask, which could otherwise leave the key group/world-readable.
461fn write_key_pem(path: &str, contents: &str) -> Result<()> {
462    create_parent(path)?;
463    #[cfg(unix)]
464    {
465        use std::io::Write;
466        use std::os::unix::fs::OpenOptionsExt;
467        let mut file = std::fs::OpenOptions::new()
468            .write(true)
469            .create(true)
470            .truncate(true)
471            .mode(0o600)
472            .open(path)
473            .with_context(|| format!("creating {path} (mode 0600)"))?;
474        file.write_all(contents.as_bytes())
475            .with_context(|| format!("writing {path}"))?;
476        Ok(())
477    }
478    #[cfg(not(unix))]
479    {
480        std::fs::write(path, contents).with_context(|| format!("writing {path}"))
481    }
482}
483
484/// Aborts the wrapped task on drop, so the HTTP-01 challenge listener is torn down on *every*
485/// exit path from [`obtain_certificate`] — including the early `?` returns during ordering —
486/// not just the happy path. Otherwise a failed issuance would leave a stray `:80` listener
487/// that blocks the next attempt from binding.
488struct AbortOnDrop(tokio::task::JoinHandle<()>);
489
490impl Drop for AbortOnDrop {
491    fn drop(&mut self) {
492        self.0.abort();
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499    use crate::config::{AcmeCfg, TlsCfg};
500    use std::time::{SystemTime, UNIX_EPOCH};
501
502    // End-to-end HTTP-01 issuance against **Pebble** (a tiny test ACME CA), proving the ◐ roadmap
503    // item without touching Let's Encrypt's rate limits. `#[ignore]`d — it needs a running CA, the
504    // privilege to bind :80 (the challenge port is fixed by RFC 8555), and a domain that resolves
505    // to this host. Recipe:
506    //
507    //   1. Run Pebble + pebble-challtestsrv (see https://github.com/letsencrypt/pebble; a starting
508    //      compose is at loadtest/pebble.compose.yaml). challtestsrv must resolve the test domain
509    //      to the host running this test, and Pebble's HTTP-01 validation must reach this host's :80.
510    //   2. Trust Pebble's DIRECTORY certificate. Note it is signed by the *minica* root, NOT
511    //      by `https://localhost:15000/roots/0` (that one signs the certs Pebble issues):
512    //        curl -sL https://raw.githubusercontent.com/letsencrypt/pebble/main/test/certs/pebble.minica.pem     //          | sudo tee /usr/local/share/ca-certificates/pebble.crt >/dev/null
513    //        sudo update-ca-certificates
514    //      instant-acme 0.8 verifies against the platform store, so this is enough. Under 0.7
515    //      it used compiled-in roots and no amount of trust configuration could work.
516    //   3. Run it:
517    //        EDGEGUARD_TEST_ACME_DIR=https://localhost:14000/dir \
518    //        EDGEGUARD_TEST_ACME_DOMAIN=edgeguard.test \
519    //        sudo -E cargo test -p eggrd --lib acme_http01 -- --ignored
520    //      (`sudo`/CAP_NET_BIND_SERVICE so the challenge server can bind :80.)
521    #[tokio::test]
522    #[ignore = "requires a live test ACME CA (Pebble) + :80 — see the module test comment"]
523    async fn acme_http01_issues_against_pebble() {
524        let Ok(directory_url) = std::env::var("EDGEGUARD_TEST_ACME_DIR") else {
525            eprintln!("skipping acme_http01_issues_against_pebble: set EDGEGUARD_TEST_ACME_DIR");
526            return;
527        };
528        let domain =
529            std::env::var("EDGEGUARD_TEST_ACME_DOMAIN").unwrap_or_else(|_| "edgeguard.test".into());
530
531        // `main` installs the process-wide rustls provider before it reaches the ACME block
532        // (main.rs: `tls::init_crypto()` immediately precedes it), so the shipping path is
533        // fine. This test calls `obtain_certificate` directly and so has to do the same, or
534        // rustls panics on the first HTTPS request to the directory — before any ACME logic
535        // runs at all. Without this line the test cannot pass, which is why it had never
536        // reported anything despite being written.
537        crate::tls::init_crypto();
538
539        let stamp = SystemTime::now()
540            .duration_since(UNIX_EPOCH)
541            .unwrap()
542            .as_nanos();
543        let base = std::env::temp_dir().join(format!("eg-acme-{stamp}"));
544        std::fs::create_dir_all(&base).unwrap();
545        let cert_path = base.join("cert.pem").to_string_lossy().into_owned();
546        let key_path = base.join("key.pem").to_string_lossy().into_owned();
547
548        let acme = AcmeCfg {
549            enabled: true,
550            domains: vec![domain],
551            email: "ci@example.test".into(),
552            directory_url,
553            cache_dir: base.to_string_lossy().into_owned(),
554            accept_tos: true,
555            // `..default()` so a new AcmeCfg field does not break this test literal. The budget is
556            // on (its default) and inert here on purpose: Pebble's directory URL is not a
557            // recognised CA, so `CaProfile::for_directory` returns None and no bucket is charged.
558            // That is what stops this test failing on its sixth run against a rate limit Pebble
559            // does not have.
560            ..AcmeCfg::default()
561        };
562        let tls = TlsCfg {
563            enabled: true,
564            cert_path: cert_path.clone(),
565            key_path: key_path.clone(),
566            acme: acme.clone(),
567            ..TlsCfg::default()
568        };
569
570        // `None`: this test drives an unmanaged edge, so the local budget is the only authority.
571        obtain_certificate(&acme, &tls, None)
572            .await
573            .expect("ACME HTTP-01 issuance against Pebble");
574
575        let cert = std::fs::read_to_string(&cert_path).expect("issued certificate written");
576        assert!(
577            cert.contains("BEGIN CERTIFICATE"),
578            "issued PEM chain present"
579        );
580        let key = std::fs::read_to_string(&key_path).expect("private key written");
581        assert!(key.contains("BEGIN"), "private key PEM present");
582        let _ = std::fs::remove_dir_all(&base);
583    }
584}