acme_proxy/lib.rs
1//! ACME (RFC 8555) Server Implementation
2//!
3//! This is a server-side implementation of the ACME protocol (RFC 8555) for
4//! issuing and managing SSL/TLS certificates. It serves as a backend for
5//! certificate clients like certbot and acme.sh.
6//!
7//! ## Features
8//!
9//! - The full RFC 8555 flow: directory, newNonce, newAccount, account
10//! lookup/update and deactivation, newOrder, authorizations and challenges,
11//! finalize, certificate retrieval via signed POST-as-GET, and revocation
12//! - JWS signature verification for EC (ES256) and RSA (RS256) keys
13//! - Automatic nonce management with replay protection
14//! - Challenge validation behind pluggable validators (`http-01`, `dns-01`,
15//! `tls-alpn-01`), with a configurable bypass
16//! - Certificate issuance behind a pluggable signer backend: a local CA (whose
17//! key may live in a PKCS#11 token), a relay to an upstream ACME CA, or an
18//! operator-supplied script
19//! - **Profiles** — several independent ACME endpoints in one process, each with
20//! its own signer, filters, challenge validators and EAB policy
21//! - External Account Binding (§7.3.4), account key rollover (§7.3.5) and
22//! Renewal Information (RFC 9773)
23//! - An append-only audit trail of every issuance *and every refusal*
24//! - An optional web admin listener, and admin subcommands in the same binary
25//! - `SQLite` persistence for accounts, nonces, orders and the audit trail
26//! - Configurable via TOML, environment variables, or defaults
27//!
28//! ## Architecture
29//!
30//! The ACME request path, in the order a request meets it:
31//! - [`middlewares`] - Server-wide layers: request correlation and the access
32//! line, admission control, the `Replay-Nonce` and `Link: rel="index"` headers
33//! - [`filter`] - Pluggable request filtering (who may ask at all)
34//! - [`extractors`] - Parse and validate ACME JWS requests, verifying the media
35//! type, the `crit` header, the signature, the JWS `url` and the nonce before
36//! any handler runs
37//! - [`handlers`] - One module per ACME resource
38//! - [`challenge`] - Pluggable challenge validators (http-01, dns-01, tls-alpn-01)
39//! - [`signer`] - Pluggable certificate-issuance backends (local CA, ACME relay,
40//! custom script)
41//!
42//! Supporting subsystems:
43//! - [`audit`] - The durable record of who asked this CA to sign or revoke
44//! - [`notify`] - Pluggable operator notifications on lifecycle events (email,
45//! webhook, custom)
46//! - [`eab`] - Verification of the External Account Binding inner JWS (§7.3.4)
47//! - [`key_change`] - Verification of account key rollover JWS (§7.3.5)
48//! - [`dns`] - The resolver shared by every subsystem that looks anything up
49//! - [`tls`] - Optional HTTPS termination for either listener
50//! - [`cert`] - X.509 parsing helpers (serial, SPKI, leaf-from-chain)
51//! - [`sqlite`] - Database access, one module per table
52//! - [`config`] - Configuration loading from multiple sources
53//! - [`error`] - ACME error types and problem document rendering
54//!
55//! Administration, which serves no ACME and is a second listener plus a CLI:
56//! - [`admin`] - The operation layer both front ends dispatch to
57//! - [`webadmin`] - The optional HTML + JSON admin listener
58//! - [`cli`] - The `clap` command tree, and the startup path itself
59//!
60//! ## Usage
61//!
62//! The main entry point is `build_app()`, which mounts one ACME router per
63//! configured profile under `/profile/<name>` and serves the server-level
64//! routes (`/health`) at the root.
65//!
66//! ```rust,no_run
67//! use std::net::SocketAddr;
68//! use std::sync::Arc;
69//! use acme_proxy::{
70//! Profile, ProfileParts, build_app, challenge, config::Config, filter, ipam, jobs, notify,
71//! signer, sqlite::db::Database,
72//! };
73//!
74//! #[tokio::main]
75//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
76//! let config = Arc::new(Config::load()?);
77//! let database = Arc::new(Database::connect(&config.database.url).await?);
78//!
79//! let resolved = config.resolve_profiles()?;
80//! // The resolver and the proxy policy, resolved before anything can dial:
81//! // a proxy URL that cannot be understood must stop the process rather
82//! // than leave egress elsewhere, and `dns.resolver` governs every outbound
83//! // connection this server makes, not just challenge lookups. Bundled,
84//! // because every outbound client takes them together — and because the
85//! // rendering beside them is what tells a reload whether a signer backend
86//! // has to be rebuilt.
87//! let egress = Arc::new(acme_proxy::Egress::from_config(&config)?);
88//! let outbound = egress.outbound();
89//! // The enqueue side of the durable queue, built first because everything
90//! // below queues into it. A backend that defers issuance (`relay`) is
91//! // handed one at construction, and so is every notify dispatcher — a
92//! // notification is a job row too. The runner that drains it is started
93//! // separately, below.
94//! let job_queue = jobs::JobQueue::new(database.clone(), &config.jobs);
95//! // Built once, up front: an asynchronous signer backend (`relay`)
96//! // has no `Profile` to reach a notifier through from its background
97//! // completion task, so it is handed this whole map instead — and so is
98//! // the `NotifyJob` that performs the deliveries.
99//! let mut notifiers = std::collections::HashMap::new();
100//! for profile in &resolved {
101//! notifiers.insert(
102//! profile.name.clone(),
103//! notify::from_config(
104//! &profile.name,
105//! &profile.sections.notify,
106//! outbound.clone(),
107//! &job_queue,
108//! )?,
109//! );
110//! }
111//! let notifiers = Arc::new(notifiers);
112//! // The Prometheus counters. Built here rather than per generation, so a
113//! // `SIGHUP` does not reset every counter to zero — see `Assembly`.
114//! let metrics = Arc::new(acme_proxy::metrics::Metrics::new(database.clone()));
115//!
116//! let mut profiles = Vec::new();
117//! for profile in &resolved {
118//! let sections = &profile.sections;
119//! profiles.push(Arc::new(Profile::new(
120//! &profile.name,
121//! &config.server.base_url,
122//! ProfileParts {
123//! signer: signer::from_config(
124//! §ions.signer,
125//! vec![profile.name.clone()],
126//! &signer::SignerParts {
127//! database: database.clone(),
128//! notifiers: notifiers.clone().into(),
129//! metrics: metrics.clone(),
130//! egress: egress.clone(),
131//! jobs: job_queue.clone(),
132//! },
133//! // Nothing to adopt at startup; a reload passes what the
134//! // previous generation's backends handed over.
135//! &signer::CarriedState::new(),
136//! )?,
137//! filter: filter::from_config(
138//! §ions.filter,
139//! &config.dns,
140//! ipam::from_config(§ions.ipam, outbound.clone())?,
141//! sections.eab.enabled,
142//! )?,
143//! challenges: challenge::from_config(
144//! §ions.challenge,
145//! &config.dns,
146//! egress.proxies.clone(),
147//! )?,
148//! order: sections.order.clone(),
149//! eab: sections.eab.clone(),
150//! meta: sections.meta.clone(),
151//! notify: notifiers[&profile.name].clone(),
152//! },
153//! )));
154//! }
155//! // Process-wide, like `[audit]` itself: one trail for the whole CA,
156//! // shared by every profile's router and by the web admin listener.
157//! // The registry is a parameter rather than a builder step, so a serving
158//! // process cannot build an auditor that counts into nothing. The counters
159//! // come off the same `AuditRecord` the trail is written from, so the two
160//! // can never disagree.
161//! let audit = Arc::new(acme_proxy::audit::Auditor::from_config(
162//! &config.audit,
163//! &config.dns,
164//! database.clone(),
165//! metrics.clone(),
166//! )?);
167//! let app = build_app(
168//! database.clone(),
169//! config.clone(),
170//! profiles,
171//! audit,
172//! metrics.clone(),
173//! );
174//!
175//! // One runner drains the queue for the process. Every handler comes from
176//! // a subsystem that has background work — `SignerBackend::jobs`,
177//! // notification delivery, and the periodic table sweeps — and the runner
178//! // calls `recover` on each before it claims anything, which is how work a
179//! // previous run left in flight is picked back up, and how each sweep's
180//! // single row gets queued.
181//! let mut registry = jobs::JobRegistry::new();
182//! registry.register(Arc::new(notify::NotifyJob::new(notifiers)))?;
183//! registry.register(Arc::new(jobs::SweepJob::nonces(
184//! database.clone(),
185//! std::time::Duration::from_secs(config.nonce.ttl_seconds),
186//! )))?;
187//! let (_shutdown, shutdown_rx) = tokio::sync::watch::channel(false);
188//! jobs::spawn_runner(job_queue, Arc::new(registry), &config.jobs, shutdown_rx);
189//!
190//! let listener = tokio::net::TcpListener::bind(&config.server.bind_address).await?;
191//! axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
192//!
193//! Ok(())
194//! }
195//! ```
196
197use std::sync::Arc;
198
199use axum::body::Body;
200use axum::http::{HeaderValue, Request, header};
201use axum::{
202 Router,
203 extract::DefaultBodyLimit,
204 middleware,
205 middleware::Next,
206 response::Redirect,
207 routing::{get, post},
208};
209use tower_http::set_header::SetResponseHeaderLayer;
210use tracing::{Span, info};
211
212pub mod admin;
213pub mod audit;
214pub mod cert;
215pub mod challenge;
216pub mod cli;
217pub mod config;
218pub mod dns;
219pub mod eab;
220pub mod error;
221pub mod extractors;
222pub mod filter;
223pub mod handlers;
224pub mod http_client;
225pub mod ipam;
226pub mod jobs;
227pub mod key_change;
228pub mod listener;
229pub mod metrics;
230pub mod middlewares;
231pub mod notify;
232pub mod pemfile;
233pub mod proxy;
234pub mod reload;
235pub mod script_hook;
236pub mod signer;
237pub mod sqlite;
238mod templating;
239#[cfg(test)]
240pub(crate) mod testutil;
241pub mod tls;
242pub mod webadmin;
243
244use crate::challenge::ChallengeRegistry;
245use crate::config::Config;
246use crate::error::Problem;
247use crate::filter::FilterPolicy;
248use crate::notify::NotifyDispatcher;
249use crate::signer::SignerBackend;
250use crate::sqlite::db::Database;
251
252// Re-export name shape helpers for backwards compatibility
253pub use handlers::helpers::{is_wildcard, normalize_dns_name, well_formed_name};
254
255/// The ACME resource paths, profile-relative.
256///
257/// One definition each, because they are written in three places that must
258/// agree and previously agreed only by inspection: the router that *mounts*
259/// them (`build_router`), the directory that *advertises* them
260/// (`handlers::get_directory`), and `middlewares::nonce`, which singles out
261/// `newNonce`. A directory advertising a path nothing serves is a client that
262/// fails on its very first request, and nothing structural caught it.
263///
264/// Only the resources with a fixed path are here; the id-bearing ones
265/// (`/acct/{id}`, `/order/{id}/finalize`, …) are never advertised, so they have
266/// exactly one call site and gain nothing from a constant.
267pub mod routes {
268 pub const DIRECTORY: &str = "/directory";
269 pub const NEW_NONCE: &str = "/newNonce";
270 pub const NEW_ACCOUNT: &str = "/newAccount";
271 pub const NEW_ORDER: &str = "/newOrder";
272 pub const REVOKE_CERT: &str = "/revokeCert";
273 pub const KEY_CHANGE: &str = "/keyChange";
274 /// RFC 9773 §4.1 has the client append the certID, so the directory
275 /// advertises this bare while the router mounts `{id}` under it.
276 pub const RENEWAL_INFO: &str = "/renewalInfo";
277 pub const CRL: &str = "/crl";
278 /// The trust anchor a client installs to accept this profile's leaves.
279 /// Routed beside [`CRL`] and, like it, deliberately not advertised in the
280 /// directory — both are CA infrastructure rather than ACME resources.
281 pub const CA_CHAIN: &str = "/ca.pem";
282}
283
284/// The URL namespace every ACME endpoint is mounted under: a profile named
285/// `le` serves `/profile/le/directory`.
286///
287/// Reserved and fixed, which is the point — server-level routes live at the
288/// root and a profile can never collide with one, now or when the next one is
289/// added.
290pub const PROFILE_PREFIX: &str = "/profile";
291
292/// A duration in milliseconds, as a log field.
293///
294/// `Duration::as_millis` returns `u128`, which `tracing` has no primitive
295/// visitor for and so records through `Display` — landing in the JSON output as
296/// a quoted `"42"` rather than the number `42`. That output exists to be
297/// aggregated by machines, and a latency field a collector has to re-parse (or
298/// silently indexes as a string) is a defect in it. Every duration logged
299/// anywhere in this crate goes through here.
300///
301/// The saturation is unreachable — `u64::MAX` milliseconds is some 584 million
302/// years — and is written out only to avoid a silent truncating cast.
303#[must_use]
304pub fn millis(duration: std::time::Duration) -> u64 {
305 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
306}
307
308/// One ACME endpoint: its identity, its URLs, and the three subsystems that
309/// answer for it.
310///
311/// Everything per-endpoint lives here rather than beside the global config in
312/// [`AppState`], so a handler cannot pair one profile's signer with another's
313/// base URL — the two always travel together.
314pub struct Profile {
315 /// The configured name (`[profiles.<name>]`), also the URL segment and the
316 /// value stored in `accounts.profile` / `orders.profile`.
317 pub name: String,
318 /// Where the router mounts it: `/profile/<name>`.
319 pub path: String,
320 /// The public base for every URL this endpoint hands out and for the
321 /// RFC 8555 §6.4 `url` check: `server.base_url` + [`Profile::path`].
322 pub base_url: String,
323 pub signer: Arc<dyn SignerBackend>,
324 pub filter: Arc<FilterPolicy>,
325 pub challenges: Arc<ChallengeRegistry>,
326 pub order: config::OrderConfig,
327 pub eab: config::EabConfig,
328 /// The optional `meta` members this endpoint's directory advertises
329 /// (RFC 8555 §7.1.1). Per-profile, like everything else here: two endpoints
330 /// on one process can have different terms of service.
331 pub meta: config::MetaConfig,
332 pub notify: Arc<NotifyDispatcher>,
333}
334
335/// The subsystems and per-endpoint sections a [`Profile`] is assembled from.
336///
337/// A struct because [`Profile::new`] took nine positional parameters, four of
338/// them `Arc<dyn …>` or config sections that a reader has to count commas to
339/// tell apart. It also retires the crate's last
340/// `#[allow(clippy::too_many_arguments)]`.
341///
342/// `name` and `base_url` stay positional: they are what the constructor
343/// *derives* from rather than stores, and keeping them out of here is what
344/// makes "the path is never configured" visible in the signature.
345pub struct ProfileParts {
346 pub signer: Arc<dyn SignerBackend>,
347 pub filter: Arc<FilterPolicy>,
348 pub challenges: Arc<ChallengeRegistry>,
349 pub order: config::OrderConfig,
350 pub eab: config::EabConfig,
351 pub meta: config::MetaConfig,
352 pub notify: Arc<NotifyDispatcher>,
353}
354
355impl Profile {
356 /// Assembles a profile, deriving its path and base URL from its name —
357 /// the two are never configured, so they cannot drift from each other or
358 /// from what the database records.
359 pub fn new(name: &str, base_url: &str, parts: ProfileParts) -> Self {
360 let path = format!("{PROFILE_PREFIX}/{name}");
361 Self {
362 name: name.to_string(),
363 base_url: format!("{}{path}", base_url.trim_end_matches('/')),
364 path,
365 signer: parts.signer,
366 filter: parts.filter,
367 challenges: parts.challenges,
368 order: parts.order,
369 eab: parts.eab,
370 meta: parts.meta,
371 notify: parts.notify,
372 }
373 }
374
375 /// This endpoint's directory URL — where a client starts.
376 ///
377 /// Derived here rather than `format!`-ed at each of the three call sites
378 /// (the startup log line, the admin API's profile listing, and anything
379 /// added later), all of which have to agree with what `build_router`
380 /// actually mounts.
381 #[must_use]
382 pub fn directory_url(&self) -> String {
383 format!("{}{}", self.base_url, routes::DIRECTORY)
384 }
385
386 /// Builds every endpoint this configuration mounts, ready to serve.
387 ///
388 /// Lives here rather than in `cli::serve_on` because it is the assembly step,
389 /// not dispatch: it resolves the profiles, builds the signer backends
390 /// (deduplicated by configuration — see [`signer::build_backends`]), and
391 /// gives each profile its own filter chain and challenge registry. Every
392 /// failure is fatal at startup, so they come back as one error for the
393 /// caller to report and exit on.
394 ///
395 /// Each profile's subsystems are built inside a span naming it, so the
396 /// warnings they emit at build time (`filter_disabled`,
397 /// `challenge_validation_bypassed`) say *which* endpoint is wide open —
398 /// with several mounted, an unattributed warning is worse than none.
399 /// `jobs` is the enqueue side of the durable queue, handed in rather than
400 /// built here for the reason the `Auditor` is built in `serve_on_with`:
401 /// `[jobs]` is process-wide, one queue drained by one runner, and a profile
402 /// is not the thing that owns it.
403 pub fn build_all(
404 config: &Config,
405 database: Arc<Database>,
406 jobs: &crate::jobs::JobQueue,
407 ) -> anyhow::Result<Vec<Arc<Profile>>> {
408 let resolved = config.resolve_profiles()?;
409 let (_assembly, first) = Assembly::new(&resolved, database, jobs.clone(), config)?;
410 Self::build_all_with(config, &resolved, &first)
411 }
412
413 /// One generation of profiles, over an [`Assembly`] that outlives it.
414 ///
415 /// The half of [`build_all`](Self::build_all) a configuration reload runs
416 /// again. Everything it touches is cheap and side-effect-free to rebuild —
417 /// a filter policy, an IPAM client, a challenge registry — which is exactly
418 /// why the *stateful* half lives in the `Assembly` instead. The signer
419 /// backends are the interesting middle case: they are rebuilt here too, but
420 /// only the ones whose configuration actually moved, and those adopt what
421 /// the outgoing instance held (see [`signer::build_backends`]).
422 pub fn build_all_with(
423 config: &Config,
424 resolved: &[config::ProfileConfig],
425 generation: &GenerationParts,
426 ) -> anyhow::Result<Vec<Arc<Profile>>> {
427 let egress = &generation.egress;
428 let dispatchers = &generation.dispatchers;
429 let backends = &generation.signers;
430
431 let mut profiles = Vec::with_capacity(resolved.len());
432 for profile in resolved {
433 let sections = &profile.sections;
434 let span = tracing::info_span!("profile", profile = %profile.name);
435 let (filter, challenges) = span.in_scope(|| {
436 // Built per profile with no dedup pass, unlike
437 // `signer::build_backends`. Sharing a signer backend is a
438 // correctness requirement — two `LocalCa` over one CRL file
439 // would clobber each other's ledger — whereas an IPAM client
440 // owns no files and holds no mutable state, so two profiles
441 // naming the same inventory each building one costs nothing
442 // but a `rustls::ClientConfig`.
443 let ipam = ipam::from_config(§ions.ipam, egress.outbound())
444 .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
445 let filter =
446 filter::from_config(§ions.filter, &config.dns, ipam, sections.eab.enabled)
447 .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
448 let challenges = challenge::from_config(
449 §ions.challenge,
450 &config.dns,
451 egress.proxies.clone(),
452 )
453 .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
454 check_request_timeout(config, profile.name.as_str(), sections)?;
455 Ok::<_, anyhow::Error>((filter, challenges))
456 })?;
457
458 profiles.push(Arc::new(Profile::new(
459 &profile.name,
460 &config.server.base_url,
461 ProfileParts {
462 signer: backends
463 .get(&profile.name)
464 .ok_or_else(|| {
465 anyhow::anyhow!("profile `{}`: no signer backend", profile.name)
466 })?
467 .clone(),
468 filter,
469 challenges,
470 order: sections.order.clone(),
471 eab: sections.eab.clone(),
472 meta: sections.meta.clone(),
473 notify: dispatchers[&profile.name].clone(),
474 },
475 )));
476 }
477 Ok(profiles)
478 }
479}
480
481/// The outbound plumbing one configuration generation dials through, and the
482/// identity of the configuration it came from.
483///
484/// `[dns]` and `[proxy]` are process-wide but no longer frozen, so they belong
485/// to a *generation* rather than to the [`Assembly`]: a reload builds a fresh
486/// resolver and proxy policy from the file, and every subsystem that reaches the
487/// network is handed this generation's pair. The signer backends look like the
488/// exception and are not — they cache what they were built with, so
489/// [`signer::build_backends`] folds `identity` into a backend's identity key and
490/// rebuilds any backend whose egress moved. Keeping the identity here rather
491/// than beside the call site is what stops the two disagreeing, which would make
492/// a `dns.resolver` edit a silent no-op for every signer.
493pub struct Egress {
494 /// Uncached, for the reason `challenge::build_resolver` explains: a client
495 /// publishing a `dns-01` record moments before triggering must not be
496 /// defeated by a cached negative answer.
497 pub resolver: Arc<dyn dns::Resolver>,
498 pub proxies: Arc<proxy::OutboundProxies>,
499 /// `[dns]` and `[proxy]` rendered. Only ever compared to another one — never
500 /// parsed, never shown — which is the same contract `signer::build_backends`
501 /// keys a `[signer]` section on.
502 pub identity: String,
503}
504
505impl Egress {
506 /// Builds both clients from `config`.
507 ///
508 /// Fallible for two separate reasons worth keeping apart: a proxy URL that
509 /// cannot be understood, and a `dns.resolver` that is not a socket address.
510 /// Both must stop a startup and refuse a reload rather than degrade — a
511 /// server that silently fell back to direct egress would dial around exactly
512 /// the control its operator configured.
513 pub fn from_config(config: &Config) -> anyhow::Result<Self> {
514 let proxies = crate::proxy::from_config(&config.proxy)?;
515 // One resolver per generation, handed to every subsystem that makes an
516 // outbound connection. `dns.resolver` is documented as "the nameserver
517 // every DNS lookup this server makes goes through", and three of the
518 // four HTTP clients used to bypass it — so an operator on a
519 // split-horizon estate had NetBox and their upstream CA resolving
520 // differently from the challenge validators, with nothing saying so.
521 let resolver = challenge::build_resolver(crate::dns::resolver_addr(&config.dns)?)?;
522 Ok(Self {
523 resolver,
524 proxies,
525 identity: format!("{:?}|{:?}", config.dns, config.proxy),
526 })
527 }
528
529 /// The resolver and proxy policy as one value, for the subsystems that make
530 /// outbound HTTP requests.
531 ///
532 /// An accessor rather than a stored field: `challenge::from_config` builds
533 /// its *own* resolver past the bypass branch (constructing one is what
534 /// reads `/etc/resolv.conf`, so it must not happen when validation is off),
535 /// and so needs the proxy half on its own.
536 #[must_use]
537 pub fn outbound(&self) -> http_client::Outbound {
538 http_client::Outbound::new(self.resolver.clone(), self.proxies.clone())
539 }
540}
541
542/// The three things one configuration generation contributes to its profiles,
543/// built before any of them is published.
544///
545/// A struct because [`Profile::build_all_with`] would otherwise take three
546/// same-shaped values positionally, and because the three are built together and
547/// must be published together — `cli::apply_reload` swaps the notifier map and
548/// the signer set in the same uninterruptible run as the routers built from
549/// them.
550pub struct GenerationParts {
551 pub egress: Arc<Egress>,
552 pub dispatchers: notify::DispatcherMap,
553 pub signers: signer::SignerSet,
554}
555
556/// What survives a configuration reload.
557///
558/// Every generation rebuilds its profiles, its routers, its job registry, its
559/// egress clients and any signer backend whose configuration moved. The things
560/// here are built once for the life of the process and handed to each generation
561/// instead — and after three rounds of moving things *off* this list, everything
562/// left is here because rebuilding it would lose something, never because
563/// rebuilding it would merely cost something:
564///
565/// - `database` and `jobs` are the pool and its enqueue side. `database.url` is
566/// the one key [`crate::reload`] still refuses, and this is why.
567/// - `metrics` is a **correctness** requirement. A registry rebuilt per
568/// generation would reset every counter on `SIGHUP`, and a counter going
569/// backwards is precisely how Prometheus recognises a process restart — so
570/// `rate()` would report the whole pre-reload total as a spike on every
571/// configuration change.
572/// - `signers` is the *previous* generation's backend set, kept so the next
573/// reload can reuse a backend whose configuration did not move and hand the
574/// live in-memory state of one that did to its replacement (see
575/// [`signer::CarriedState`]). Behind a `Mutex` because it is written once per
576/// generation; nothing reads it to serve a request, since a `Profile` holds
577/// its own `Arc<dyn SignerBackend>`.
578/// - `notifiers` is a handle rather than a map, so `[notify]` can reload
579/// underneath the backends that captured it.
580///
581/// `resolver` and `proxies` used to be here, justified by the signers caching
582/// them at construction. They moved to [`Egress`] when that stopped being a
583/// reason to freeze `[dns]`/`[proxy]` and became a reason to rebuild a signer.
584pub struct Assembly {
585 pub database: Arc<Database>,
586 pub jobs: crate::jobs::JobQueue,
587 pub metrics: Arc<metrics::Metrics>,
588 pub notifiers: notify::Notifiers,
589 notifiers_tx: notify::NotifiersSender,
590 signers: std::sync::Mutex<signer::SignerSet>,
591}
592
593impl Assembly {
594 /// Builds everything that outlives a generation, plus the first generation's
595 /// own parts.
596 ///
597 /// Those come back rather than being kept here because they are *not*
598 /// long-lived: the caller hands them to [`Profile::build_all_with`] and then
599 /// forgets them, and every later generation builds its own through
600 /// [`build_parts`](Self::build_parts).
601 pub fn new(
602 resolved: &[config::ProfileConfig],
603 database: Arc<Database>,
604 jobs: crate::jobs::JobQueue,
605 config: &Config,
606 ) -> anyhow::Result<(Self, GenerationParts)> {
607 // Built before the signers, because the `relay` backend settles an
608 // issuance from a background task that has no request and no `Auditor`,
609 // so it counts that issuance through a handle it was given at
610 // construction.
611 let metrics = Arc::new(metrics::Metrics::new(database.clone()));
612 // Opened over an empty map and republished immediately below, so the
613 // handle the signers capture is the one every later generation writes
614 // into.
615 let (notifiers_tx, notifiers) = notify::notifiers_channel(notify::DispatcherMap::new());
616
617 let assembly = Self {
618 database,
619 jobs,
620 metrics,
621 notifiers,
622 notifiers_tx,
623 signers: std::sync::Mutex::new(signer::SignerSet::default()),
624 };
625 let parts = assembly.build_parts(resolved, config)?;
626 // The first generation's map has to reach the handle before anything
627 // dispatches through it; every later one goes through `publish` in the
628 // reload's own synchronous run.
629 assembly.publish_notifiers(parts.dispatchers.clone());
630 assembly.publish_signers(parts.signers.clone());
631 Ok((assembly, parts))
632 }
633
634 /// Builds one generation's egress, dispatchers and signer backends, without
635 /// publishing any of them.
636 ///
637 /// Separate from [`publish_notifiers`](Self::publish_notifiers) and
638 /// [`publish_signers`](Self::publish_signers) because a reload must be able
639 /// to fail *after* building all three and still leave the running generation
640 /// untouched. Everything fallible is here; everything published is there.
641 ///
642 /// May block: `RelaySigner::from_config` contacts the upstream the first
643 /// time it is built for an account with no `kid` sidecar yet, which is why
644 /// `cli::supervise_reloads` runs this on a blocking thread.
645 pub fn build_parts(
646 &self,
647 resolved: &[config::ProfileConfig],
648 config: &Config,
649 ) -> anyhow::Result<GenerationParts> {
650 // Resolved before anything can dial: a proxy URL that cannot be
651 // understood must stop the process, and the `relay` backend below makes
652 // a real network call on its very first startup.
653 let egress = Arc::new(Egress::from_config(config)?);
654 // Built before the signer backends: the `relay` backend's background
655 // completion task has no `Profile`/`AppState` to reach a notifier
656 // through (it outlives any single request, the same reason it is handed
657 // `database`), so it is instead handed the whole `profile name ->
658 // dispatcher` map and looks up the right one by `Order.profile` once an
659 // issuance settles.
660 let dispatchers = notify::build_registry(resolved, egress.outbound(), &self.jobs)?;
661 let previous = self
662 .signers
663 .lock()
664 .unwrap_or_else(std::sync::PoisonError::into_inner)
665 .clone();
666 let signers = signer::build_backends(
667 resolved,
668 &signer::SignerParts {
669 database: self.database.clone(),
670 notifiers: self.notifiers.clone(),
671 metrics: self.metrics.clone(),
672 egress: egress.clone(),
673 jobs: self.jobs.clone(),
674 },
675 &previous,
676 )?;
677
678 Ok(GenerationParts {
679 egress,
680 dispatchers,
681 signers,
682 })
683 }
684
685 /// Makes `dispatchers` the generation every long-lived reader sees.
686 ///
687 /// Synchronous, and deliberately: it is one of the sends a reload makes
688 /// back-to-back so no task can observe a half-swapped generation.
689 pub fn publish_notifiers(&self, dispatchers: notify::DispatcherMap) {
690 self.notifiers_tx.send_replace(Arc::new(dispatchers));
691 }
692
693 /// Records `signers` as what the *next* reload compares against, and drops
694 /// whatever the generation before it held.
695 ///
696 /// That drop is the point at which a backend nobody references any more —
697 /// an unmounted profile's, or the instance a `[signer]` edit replaced — is
698 /// finally released. Deliberately after its replacement was built and has
699 /// adopted its state, never before.
700 pub fn publish_signers(&self, signers: signer::SignerSet) {
701 *self
702 .signers
703 .lock()
704 .unwrap_or_else(std::sync::PoisonError::into_inner) = signers;
705 }
706}
707
708/// Refuses a `server.request_timeout_ms` shorter than the work the server does
709/// *inside* a request.
710///
711/// Two hooks run inline in a handler rather than in the background: challenge
712/// validation (`post_challenge` awaits `challenges.validate`) and the `custom`
713/// signer's script (`post_finalize` awaits it). If the request deadline is the
714/// shorter of the two budgets, a validation that was going to succeed is cut
715/// off and the client is told the server failed — a misconfiguration that would
716/// look like an intermittent CA outage and be miserable to diagnose. Cheaper to
717/// refuse to start and say which two numbers disagree.
718fn check_request_timeout(
719 config: &Config,
720 name: &str,
721 sections: &config::ProfileSections,
722) -> anyhow::Result<()> {
723 let deadline = config.server.request_timeout_ms;
724 let inline = [
725 ("challenge.timeout_ms", sections.challenge.timeout_ms),
726 (
727 "signer.custom.timeout_ms",
728 // Only when that backend is the one actually installed; an unused
729 // `[signer.custom]` section says nothing about this profile.
730 if sections.signer.backend == "custom" {
731 sections.signer.custom.timeout_ms
732 } else {
733 0
734 },
735 ),
736 ];
737
738 for (key, budget) in inline {
739 anyhow::ensure!(
740 deadline > budget,
741 "profile `{name}`: server.request_timeout_ms ({deadline}) must exceed {key} \
742 ({budget}) — that hook runs inside the request, so a shorter deadline would cut \
743 off work that was going to succeed and report it to the client as a server failure",
744 );
745 }
746 Ok(())
747}
748
749/// Shared application state handed to every route via `State<AppState>`.
750#[derive(Clone)]
751pub struct AppState {
752 pub database: Arc<Database>,
753 /// Process-wide configuration only — `server`, `nonce`, `dns`, `logging`.
754 /// Anything an endpoint can differ on is on [`AppState::profile`].
755 pub config: Arc<Config>,
756 pub profile: Arc<Profile>,
757 /// The CA's audit trail. Beside `config` rather than on the profile,
758 /// because `[audit]` is process-wide: the trail describes the CA, and the
759 /// web admin writes to the same one across every endpoint it can revoke on.
760 pub audit: Arc<audit::Auditor>,
761}
762
763/// Every distinct `http-01` token store across the mounted profiles.
764///
765/// Deduplicated by pointer: [`signer::build_backends`] already shares one
766/// backend instance between profiles with identical `[signer]` sections, so
767/// several profiles usually contribute the *same* store. Two profiles relaying
768/// to two different upstreams contribute two, and the route consults both —
769/// there is nothing to isolate, because the token is the upstream's own random
770/// value and is itself the secret (RFC 8555 §8.3), so one merged view cannot
771/// answer the wrong challenge.
772fn http01_stores(profiles: &[Arc<Profile>]) -> Vec<Arc<dyn signer::Http01TokenStore>> {
773 let mut stores: Vec<Arc<dyn signer::Http01TokenStore>> = Vec::new();
774 for profile in profiles {
775 if let Some(store) = profile.signer.http01_tokens()
776 && !stores.iter().any(|existing| Arc::ptr_eq(existing, &store))
777 {
778 stores.push(store);
779 }
780 }
781 stores
782}
783
784/// Builds the whole HTTP service: the server-level routes at the root, and one
785/// ACME router per profile under `/profile/<name>`.
786/// The three response-hardening headers **both** listeners apply.
787///
788/// A shared constructor rather than two copies: the admin router is not nested
789/// inside [`build_app`] and so inherits none of its layers, but these three are
790/// a security control, and two hand-written copies of one are a control that
791/// drifts. Everything genuinely per-listener — the admin's `Cache-Control`,
792/// `Referrer-Policy` and CSP, this one's admission and nonce layers — stays at
793/// its own call site.
794///
795/// A tuple because `tower` implements [`Layer`](tower::Layer) for one, so the
796/// three still apply as three separate layers rather than being collapsed into
797/// a wrapper type. They set distinct headers, so their order among themselves
798/// carries no meaning.
799pub(crate) fn security_headers() -> (
800 SetResponseHeaderLayer<HeaderValue>,
801 SetResponseHeaderLayer<HeaderValue>,
802 SetResponseHeaderLayer<HeaderValue>,
803) {
804 (
805 SetResponseHeaderLayer::overriding(
806 header::STRICT_TRANSPORT_SECURITY,
807 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
808 ),
809 SetResponseHeaderLayer::overriding(
810 header::X_CONTENT_TYPE_OPTIONS,
811 HeaderValue::from_static("nosniff"),
812 ),
813 SetResponseHeaderLayer::overriding(
814 header::X_FRAME_OPTIONS,
815 HeaderValue::from_static("DENY"),
816 ),
817 )
818}
819
820pub fn build_app(
821 database: Arc<Database>,
822 config: Arc<Config>,
823 profiles: Vec<Arc<Profile>>,
824 audit: Arc<audit::Auditor>,
825 metrics: Arc<metrics::Metrics>,
826) -> Router {
827 // Server-level routes. Deliberately *outside* the admission limit below: a
828 // health probe is asked for precisely when the server is saturated, and
829 // inside the limit it was starved exactly when it mattered — a load
830 // balancer would go on reporting the server healthy right up to the point
831 // where the probe itself could no longer get a slot.
832 let mut root = Router::new()
833 .route("/", get(|| async { Redirect::temporary("/health") }))
834 .route("/health", get(handlers::get_health_check));
835
836 // The `http-01` responder for the *upstream's* challenge, mounted only when
837 // a signer backend has tokens to serve — which today means `relay`
838 // with `challenge_strategy = "http01"`. Here beside `/health` rather than
839 // inside a profile: RFC 8555 §8.3 fixes this path at the root of the name
840 // being certified, and the CA fetching it holds no account at this server,
841 // so it must not meet a filter chain, a nonce or an ACME 404.
842 let stores = http01_stores(&profiles);
843 if !stores.is_empty() {
844 info!(
845 event = "http_01_responder_mounted",
846 outcome = "advisory",
847 path = challenge::http_01::WELL_KNOWN_PREFIX,
848 stores = stores.len(),
849 "a reverse proxy must forward or redirect \
850 http://<identifier>:80/.well-known/acme-challenge/ here for the upstream to reach it"
851 );
852 root = root.merge(
853 Router::new()
854 .route(
855 &format!("{}{{token}}", challenge::http_01::WELL_KNOWN_PREFIX),
856 get(handlers::get_challenge_file),
857 )
858 .with_state(handlers::Http01Stores(Arc::new(stores))),
859 );
860 }
861
862 let mut acme = Router::new();
863 for profile in &profiles {
864 let path = profile.path.clone();
865 acme = acme.nest(
866 &path,
867 build_router(
868 database.clone(),
869 config.clone(),
870 profile.clone(),
871 audit.clone(),
872 ),
873 );
874 }
875
876 let server = &config.server;
877 let acme = acme
878 .layer(middleware::from_fn_with_state(
879 middlewares::admission::Admission::new(
880 server.max_concurrent_requests,
881 server.admission_wait_ms,
882 server.request_timeout_ms,
883 ),
884 middlewares::admission::admission_middleware,
885 ))
886 // Innermost of the two, so it is in force by the time
887 // `String::from_request` reads the JWS body in `verify_jws`. Without it
888 // the ceiling is axum's implicit 2 MiB, which every concurrent request
889 // may buffer and then hand to `serde_json` — for a body that is a JWS
890 // carrying at most a CSR.
891 .layer(DefaultBodyLimit::max(server.max_body_bytes));
892
893 // Server-wide layers, applied once rather than once per profile. The
894 // filter and nonce layers are deliberately *not* here: both are ACME
895 // concerns and live inside each profile's own router.
896 let app = root.merge(acme);
897
898 // Counting sits here even though the exposition is served on a *different*
899 // socket (see `metrics_app`): this is the only router that sees an ACME
900 // request, and the registry both share is an `Arc`. On the merged router
901 // rather than inside a profile, because `Router::layer` applies per route
902 // *and* to the fallback — so a request that matched nothing is counted too,
903 // under `ROUTE_UNMATCHED`. It also runs after routing, which is what makes
904 // `MatchedPath` present: the label has to be the route *pattern*
905 // (`/order/{id}`), never the URI, or every order ever finalized would be
906 // its own series for as long as the scraper retained it.
907 //
908 // Added only when the listener exists, so an operator who has not asked for
909 // metrics pays neither the lock nor the allocation per request.
910 let app = if config.metrics.enabled {
911 app.layer(middleware::from_fn_with_state(
912 metrics,
913 middlewares::metrics::record_request,
914 ))
915 } else {
916 app
917 };
918
919 app.layer(security_headers())
920 // Outermost of everything, so the `request` span it opens — and the
921 // `x-request-id` it echoes — covers every route, the admission layer
922 // and the two hardening layers alike. Nothing below it is allowed to
923 // log without an id.
924 .layer(middleware::from_fn(
925 middlewares::access::add_access_middleware,
926 ))
927}
928
929/// Builds the metrics listener's router: `GET /metrics` and nothing else.
930///
931/// A **third socket**, not a route on either of the other two. The port is the
932/// access control — see [`crate::config::MetricsConfig`] — which is why there
933/// is no session extractor here and no filter chain, and why the exposition can
934/// name every profile without that being a decision about the public listener.
935///
936/// Deliberately none of `build_app`'s layers. There is no admission control (a
937/// scrape is wanted *most* when the server is saturated, the reason `/health`
938/// sits outside it too), no `Replay-Nonce`, no `Link: rel="index"`, no
939/// `DefaultBodyLimit` (a `GET` with no body), and no security headers — those
940/// exist for a browser, and nothing renders this. It keeps only the access
941/// middleware, so a scrape is a `request_completed` line like everything else
942/// and its `x-request-id` correlates with whatever it was measuring.
943///
944/// This router is **not** behind a [`reload`] swap cell, unlike the other two.
945/// It has one route, and its only state is the registry — which by design is
946/// carried across generations rather than rebuilt (see [`Assembly`]), so there
947/// is nothing a reload could put in a new one. `metrics.enabled` and
948/// `metrics.bind_address` are frozen for the reason every bind address is: the
949/// socket cannot move under a running listener.
950pub fn metrics_app(metrics: Arc<metrics::Metrics>) -> Router {
951 Router::new()
952 .route("/metrics", get(handlers::get_metrics))
953 .with_state(handlers::MetricsState(metrics))
954 .layer(middleware::from_fn(
955 middlewares::access::add_access_middleware,
956 ))
957}
958
959/// Builds one profile's ACME router: every RFC 8555 resource, plus the two
960/// layers that are per-endpoint (its filter chain) or ACME-specific (the
961/// `Replay-Nonce` minting).
962///
963/// Paths here are relative to the mount point — `axum::Router::nest` strips
964/// the prefix before this router sees a request, which is also what makes
965/// `verify_jws`'s `base_url + path` reconstruction correct.
966pub fn build_router(
967 database: Arc<Database>,
968 config: Arc<Config>,
969 profile: Arc<Profile>,
970 audit: Arc<audit::Auditor>,
971) -> Router {
972 let filter = profile.filter.clone();
973 let state = AppState {
974 database: database.clone(),
975 config,
976 profile: profile.clone(),
977 audit,
978 };
979
980 let profile_name = profile.name.clone();
981
982 // RFC 8555 §7.1 — the `index` link every resource but the directory carries.
983 // Built once here rather than per response; an invalid header value is
984 // impossible for a URL that already passed config validation, but falling
985 // back to skipping the layer beats panicking a whole endpoint over it.
986 let index_link =
987 HeaderValue::from_str(&format!("<{}/directory>;rel=\"index\"", profile.base_url));
988
989 let router = Router::<AppState>::new()
990 // §6.3: the directory and newNonce MUST answer a plain GET *and* a
991 // POST-as-GET. The extra methods chain onto one `MethodRouter` —
992 // registering the same path twice would replace the first route.
993 .route(
994 routes::DIRECTORY,
995 get(handlers::get_directory).post(handlers::post_directory),
996 )
997 .route(
998 routes::NEW_NONCE,
999 get(handlers::get_new_nonce)
1000 .head(handlers::head_new_nonce)
1001 .post(handlers::post_new_nonce),
1002 )
1003 .route(routes::NEW_ACCOUNT, post(handlers::post_new_account))
1004 .route("/acct/{id}", post(handlers::post_account))
1005 .route("/acct/{id}/orders", post(handlers::post_account_orders))
1006 .route(routes::KEY_CHANGE, post(handlers::post_key_change))
1007 .route(routes::NEW_ORDER, post(handlers::post_new_order))
1008 .route("/order/{id}", post(handlers::post_order))
1009 .route("/order/{id}/finalize", post(handlers::post_finalize))
1010 .route("/authz/{id}", post(handlers::post_authz))
1011 .route("/chall/{id}", post(handlers::post_challenge))
1012 .route("/certificate/{id}", post(handlers::post_certificate))
1013 .route(routes::REVOKE_CERT, post(handlers::post_revoke_cert))
1014 .route(
1015 &format!("{}/{{id}}", routes::RENEWAL_INFO),
1016 get(handlers::get_renewal_info),
1017 )
1018 .route(routes::CRL, get(handlers::get_crl))
1019 .route(routes::CA_CHAIN, get(handlers::get_ca_chain))
1020 // §6.3: "if the server receives a GET request, it MUST return an error
1021 // with status code 405 (Method Not Allowed) and type `malformed`".
1022 // axum's own default gets the status right but sends an empty body, so
1023 // these two fallbacks supply the problem document — for a wrong method
1024 // and, in the same spirit, for a path that routes nowhere.
1025 .method_not_allowed_fallback(|| async {
1026 Problem::method_not_allowed("This resource must be read with POST-as-GET")
1027 })
1028 .fallback(|| async { Problem::not_found("No such resource") })
1029 .with_state(state)
1030 .layer(middleware::from_fn_with_state(
1031 filter,
1032 middlewares::filter::add_filter_middleware,
1033 ))
1034 .layer(middleware::from_fn_with_state(
1035 database.clone(),
1036 middlewares::nonce::add_nonce_middleware,
1037 ));
1038
1039 // Outermost of the profile's layers that touch a response, so the link
1040 // reaches every one of them — including the two fallbacks above and
1041 // anything a filter refuses. (The `profile` recorder below wraps this, but
1042 // only writes to the tracing span.)
1043 let router = match index_link {
1044 Ok(value) => router.layer(middleware::from_fn_with_state(
1045 value,
1046 middlewares::index_link::add_index_link_middleware,
1047 )),
1048 Err(error) => {
1049 tracing::error!(
1050 event = "request_index_link_header_invalid",
1051 outcome = "failure",
1052 base_url = %profile.base_url,
1053 error = %error,
1054 );
1055 router
1056 }
1057 };
1058
1059 // `profile` is declared `field::Empty` on the server-wide `request` span
1060 // (`middlewares::access`) and filled in here — the first layer that knows
1061 // which endpoint the request landed on, since the name comes from the
1062 // `/profile/<name>` mount point `Router::nest` has already stripped.
1063 // Ahead of every other layer of this router so a request a filter refuses
1064 // still says *which* endpoint refused it.
1065 router.layer(middleware::from_fn(
1066 move |request: Request<Body>, next: Next| {
1067 let name = profile_name.clone();
1068 async move {
1069 Span::current().record("profile", &*name);
1070 next.run(request).await
1071 }
1072 },
1073 ))
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079
1080 /// Loads a whole configuration file, the only way profile resolution can be
1081 /// exercised (it reads the raw sources — see `Config::resolve_profiles`).
1082 ///
1083 /// Holds the crate-wide `ENV_LOCK` while it does: this points
1084 /// `ACME_PROXY_CONFIG` at its own file, and the environment is process-wide.
1085 fn config_from(body: &str) -> Config {
1086 let _lock = crate::config::ENV_LOCK
1087 .lock()
1088 .unwrap_or_else(std::sync::PoisonError::into_inner);
1089 let dir = crate::testutil::TempDir::new("lib");
1090 std::fs::write(dir.join("config.toml"), body).unwrap();
1091 // SAFETY: single-threaded test; the variable is removed before return.
1092 unsafe {
1093 std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
1094 }
1095 let config = Config::load().expect("the configuration must load");
1096 unsafe {
1097 std::env::remove_var("ACME_PROXY_CONFIG");
1098 }
1099 config
1100 }
1101
1102 /// A CA-material-free configuration: `local_ca` writes files at startup, so
1103 /// each profile gets its own throwaway directory.
1104 fn two_profiles_config(dir: impl AsRef<std::path::Path>) -> Config {
1105 let dir = dir.as_ref();
1106 let a = dir.join("a");
1107 let b = dir.join("b");
1108 config_from(&format!(
1109 r#"
1110 [challenge]
1111 enabled = ["http-01"]
1112 bypass = true
1113
1114 [profiles.a]
1115 signer.local_ca.cert_path = "{a}.pem"
1116 signer.local_ca.key_path = "{a}.key"
1117 signer.local_ca.crl_path = "{a}.crl"
1118
1119 [profiles.b]
1120 challenge.bypass = false
1121 signer.local_ca.cert_path = "{b}.pem"
1122 signer.local_ca.key_path = "{b}.key"
1123 signer.local_ca.crl_path = "{b}.crl"
1124 "#,
1125 a = a.display(),
1126 b = b.display(),
1127 ))
1128 }
1129
1130 async fn database() -> Arc<Database> {
1131 Arc::new(Database::connect_in_memory().await.unwrap())
1132 }
1133
1134 #[tokio::test]
1135 async fn build_all_assembles_every_endpoint_from_its_own_configuration() {
1136 let dir = crate::testutil::TempDir::new("build");
1137 let config = two_profiles_config(&dir);
1138
1139 let profiles = Profile::build_all(
1140 &config,
1141 database().await,
1142 &crate::testutil::idle_job_queue(database().await),
1143 )
1144 .unwrap();
1145 assert_eq!(profiles.len(), 2);
1146
1147 assert_eq!(profiles[0].name, "a");
1148 assert_eq!(profiles[0].path, "/profile/a");
1149 assert_eq!(profiles[0].base_url, "http://localhost:3000/profile/a");
1150 // `a` inherits the global challenge section wholesale…
1151 assert!(profiles[0].challenges.is_bypassed());
1152 // …while `b` overrides one key of it and keeps the rest.
1153 assert!(!profiles[1].challenges.is_bypassed());
1154 assert_eq!(profiles[1].challenges.enabled_types(), ["http-01"]);
1155 }
1156
1157 #[tokio::test]
1158 async fn build_all_refuses_a_configuration_that_mounts_nothing() {
1159 let config = config_from("[server]\nbase_url = \"http://acme.test\"\n");
1160 let error = match Profile::build_all(
1161 &config,
1162 database().await,
1163 &crate::testutil::idle_job_queue(database().await),
1164 ) {
1165 Err(error) => error.to_string(),
1166 Ok(_) => panic!("a server with no endpoint must not start"),
1167 };
1168 assert!(error.contains("[profiles.default]"), "{error}");
1169 }
1170
1171 /// A subsystem that cannot be built names the endpoint it belongs to —
1172 /// with several mounted, "unknown challenge type" alone would not say where.
1173 #[tokio::test]
1174 async fn build_all_names_the_profile_a_failure_came_from() {
1175 let config = config_from(
1176 r#"
1177 [profiles.le]
1178 challenge.enabled = ["not-a-challenge"]
1179 "#,
1180 );
1181 let error = match Profile::build_all(
1182 &config,
1183 database().await,
1184 &crate::testutil::idle_job_queue(database().await),
1185 ) {
1186 Err(error) => error.to_string(),
1187 Ok(_) => panic!("an unknown challenge type is a startup error"),
1188 };
1189 assert!(error.contains("profile `le`"), "{error}");
1190 assert!(error.contains("not-a-challenge"), "{error}");
1191 }
1192
1193 /// A request deadline shorter than a hook that runs inside the request is a
1194 /// misconfiguration that would look like an intermittent CA outage: a
1195 /// validation that was going to succeed gets cut off and reported to the
1196 /// client as a server failure. Refuse to start and name both numbers.
1197 #[tokio::test]
1198 async fn build_all_refuses_a_deadline_shorter_than_an_inline_hook() {
1199 let config = config_from(
1200 r#"
1201 [server]
1202 request_timeout_ms = 1000
1203
1204 [profiles.le]
1205 challenge.timeout_ms = 5000
1206 "#,
1207 );
1208 let error = match Profile::build_all(
1209 &config,
1210 database().await,
1211 &crate::testutil::idle_job_queue(database().await),
1212 ) {
1213 Err(error) => error.to_string(),
1214 Ok(_) => panic!("a deadline below challenge.timeout_ms is a startup error"),
1215 };
1216 assert!(error.contains("profile `le`"), "{error}");
1217 assert!(error.contains("request_timeout_ms"), "{error}");
1218 assert!(error.contains("challenge.timeout_ms"), "{error}");
1219 }
1220
1221 /// The same check must not fire on `signer.custom.timeout_ms` when that
1222 /// backend is not the one installed — an unused `[signer.custom]` section
1223 /// says nothing about how long this profile's requests take.
1224 #[tokio::test]
1225 async fn an_unused_custom_signer_timeout_does_not_constrain_the_deadline() {
1226 let config = config_from(
1227 r#"
1228 [server]
1229 request_timeout_ms = 2000
1230
1231 [signer.custom]
1232 script_path = "/bin/true"
1233 timeout_ms = 30000
1234
1235 [profiles.le]
1236 challenge.timeout_ms = 1000
1237 "#,
1238 );
1239 assert!(
1240 Profile::build_all(
1241 &config,
1242 database().await,
1243 &crate::testutil::idle_job_queue(database().await)
1244 )
1245 .is_ok()
1246 );
1247 }
1248}