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}