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