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