Skip to main content

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//!                     &sections.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//!                     &sections.filter,
139//!                     &config.dns,
140//!                     ipam::from_config(&sections.ipam, outbound.clone())?,
141//!                     sections.eab.enabled,
142//!                 )?,
143//!                 challenges: challenge::from_config(
144//!                     &sections.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(&sections.ipam, egress.outbound())
444                    .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
445                let filter =
446                    filter::from_config(&sections.filter, &config.dns, ipam, sections.eab.enabled)
447                        .map_err(|error| anyhow::anyhow!("profile `{}`: {error}", profile.name))?;
448                let challenges = challenge::from_config(
449                    &sections.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}