acme_proxy_protocol/router.rs
1//! The ACME listener's routers — the whole service and one profile's — the
2//! metrics listener's, and the response layers shared with the admin listener.
3
4use std::any::Any;
5use std::sync::Arc;
6
7use axum::body::Body;
8use axum::http::{HeaderValue, Request, header};
9use axum::{
10 Router,
11 extract::DefaultBodyLimit,
12 middleware,
13 middleware::Next,
14 response::{IntoResponse, Redirect, Response},
15 routing::{get, post},
16};
17use tower_http::catch_panic::CatchPanicLayer;
18use tower_http::set_header::SetResponseHeaderLayer;
19use tracing::{Span, info};
20
21use crate::{handlers, middlewares};
22use acme_proxy_core::config::Config;
23use acme_proxy_core::error::Problem;
24use acme_proxy_core::routes;
25use acme_proxy_jobs::metrics;
26use acme_proxy_net::challenge;
27use acme_proxy_signer as signer;
28use acme_proxy_store::db::Database;
29
30use crate::profile::Profile;
31
32/// Shared application state handed to every route via `State<AppState>`.
33#[derive(Clone)]
34pub struct AppState {
35 pub database: Arc<Database>,
36 /// Process-wide configuration only — `server`, `nonce`, `dns`, `logging`.
37 /// Anything an endpoint can differ on is on [`AppState::profile`].
38 pub config: Arc<Config>,
39 pub profile: Arc<Profile>,
40 /// The CA's audit trail. Beside `config` rather than on the profile,
41 /// because `[audit]` is process-wide: the trail describes the CA, and the
42 /// web admin writes to the same one across every endpoint it can revoke on.
43 pub audit: Arc<acme_proxy_jobs::auditor::Auditor>,
44 /// The durable queue, for the work a request starts and does not finish.
45 ///
46 /// Here for `audit`'s reason — one queue, one table, one runner for the
47 /// process — rather than on the profile. `post_challenge` is its only
48 /// caller on this listener: it claims a challenge and queues the outbound
49 /// check rather than awaiting it, so a probe of a client-chosen host no
50 /// longer holds an admission permit.
51 pub jobs: acme_proxy_jobs::jobs::JobQueue,
52}
53
54/// Every distinct `http-01` token store across the mounted profiles.
55///
56/// Deduplicated by pointer: [`signer::build_backends`] already shares one
57/// backend instance between profiles with identical `[signer]` sections, so
58/// several profiles usually contribute the *same* store. Two profiles relaying
59/// to two different upstreams contribute two, and the route consults each in
60/// turn until one answers — there is nothing to isolate, because the token is
61/// the upstream's own random value and is itself the secret (RFC 8555 §8.3),
62/// so one merged view cannot answer the wrong challenge.
63///
64/// Every store built from a `[signer]` section reads the one `http01_tokens`
65/// table, so in practice the first answers and the rest are never asked. The
66/// list stays because `Http01TokenStore` is a trait: a provider that is not the
67/// database would be a second place a token can live.
68fn http01_stores(profiles: &[Arc<Profile>]) -> Vec<Arc<dyn signer::Http01TokenStore>> {
69 let mut stores: Vec<Arc<dyn signer::Http01TokenStore>> = Vec::new();
70 for profile in profiles {
71 if let Some(store) = profile.signer_info.http01_tokens()
72 && !stores.iter().any(|existing| Arc::ptr_eq(existing, &store))
73 {
74 stores.push(store);
75 }
76 }
77 stores
78}
79
80/// The three response-hardening headers **both** listeners apply.
81///
82/// A shared constructor rather than two copies: the admin router is not nested
83/// inside [`build_app`] and so inherits none of its layers, but these three are
84/// a security control, and two hand-written copies of one are a control that
85/// drifts. Everything genuinely per-listener — the admin's `Cache-Control`,
86/// `Referrer-Policy` and CSP, this one's admission and nonce layers — stays at
87/// its own call site.
88///
89/// A tuple because `tower` implements [`Layer`](tower::Layer) for one, so the
90/// three still apply as three separate layers rather than being collapsed into
91/// a wrapper type. They set distinct headers, so their order among themselves
92/// carries no meaning.
93pub fn security_headers() -> (
94 SetResponseHeaderLayer<HeaderValue>,
95 SetResponseHeaderLayer<HeaderValue>,
96 SetResponseHeaderLayer<HeaderValue>,
97) {
98 (
99 SetResponseHeaderLayer::overriding(
100 header::STRICT_TRANSPORT_SECURITY,
101 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
102 ),
103 SetResponseHeaderLayer::overriding(
104 header::X_CONTENT_TYPE_OPTIONS,
105 HeaderValue::from_static("nosniff"),
106 ),
107 SetResponseHeaderLayer::overriding(
108 header::X_FRAME_OPTIONS,
109 HeaderValue::from_static("DENY"),
110 ),
111 )
112}
113
114/// The human-readable message a panic payload carries, or a fixed fallback.
115///
116/// `std::panic::panic_any` can carry any `'static` type; the two shapes that
117/// actually occur are `panic!("literal")` (`&'static str`) and `panic!("{x}")`
118/// (`String`). Anything else is reported as the fallback — the message only
119/// reaches the log, never a response body (ASVS V16.5.1).
120pub fn panic_message(err: &(dyn Any + Send)) -> &str {
121 err.downcast_ref::<&'static str>()
122 .copied()
123 .or_else(|| err.downcast_ref::<String>().map(String::as_str))
124 .unwrap_or("a handler panicked")
125}
126
127/// The response a caught panic produces on the ACME listener.
128///
129/// Without this a panic in a handler aborts the connection with no reply, where
130/// every other refusal this server makes is an `application/problem+json`
131/// document — the reason [`middlewares::admission`]'s deadline is a hand-written
132/// `from_fn` returning [`Problem`] rather than `tower_http`'s timeout layer. The
133/// panic message goes to the log only, never the body.
134///
135/// Relies on `panic = "unwind"`: [`CatchPanicLayer`] is inert under
136/// `panic = "abort"`, which `Cargo.toml` deliberately does not set.
137fn acme_panic_response(err: Box<dyn Any + Send + 'static>) -> Response {
138 tracing::error!(
139 event = "request_handler_panicked",
140 outcome = "failure",
141 listener = "acme",
142 error = %panic_message(err.as_ref()),
143 );
144 Problem::server_internal("Internal server error").into_response()
145}
146
147/// The last-resort panic layer for the ACME listener — see [`acme_panic_response`].
148///
149/// `pub` on the same terms as [`build_app`]: the library exists so the tests and
150/// `main.rs` can reach it, and `tests/security.rs` drives this layer over a
151/// deliberately panicking route.
152pub fn catch_panic_acme() -> CatchPanicLayer<fn(Box<dyn Any + Send + 'static>) -> Response> {
153 CatchPanicLayer::custom(acme_panic_response as fn(Box<dyn Any + Send + 'static>) -> Response)
154}
155
156/// Builds the whole HTTP service: the server-level routes at the root, and one
157/// ACME router per profile under `/profile/<name>`.
158pub fn build_app(
159 database: Arc<Database>,
160 config: Arc<Config>,
161 profiles: Vec<Arc<Profile>>,
162 audit: Arc<acme_proxy_jobs::auditor::Auditor>,
163 metrics: Arc<metrics::Metrics>,
164 jobs: acme_proxy_jobs::jobs::JobQueue,
165) -> Router {
166 // Server-level routes. Deliberately *outside* the admission limit below: a
167 // health probe is asked for precisely when the server is saturated, and
168 // inside the limit it was starved exactly when it mattered — a load
169 // balancer would go on reporting the server healthy right up to the point
170 // where the probe itself could no longer get a slot.
171 let mut root = Router::new()
172 .route("/", get(|| async { Redirect::temporary("/health") }))
173 .route("/health", get(handlers::get_health_check));
174
175 // The `http-01` responder for the *upstream's* challenge, mounted only when
176 // a signer backend has tokens to serve — which today means `relay`
177 // with `challenge_strategy = "http01"`. Here beside `/health` rather than
178 // inside a profile: RFC 8555 §8.3 fixes this path at the root of the name
179 // being certified, and the CA fetching it holds no account at this server,
180 // so it must not meet a filter chain, a nonce or an ACME 404.
181 let stores = http01_stores(&profiles);
182 if !stores.is_empty() {
183 info!(
184 event = "http_01_responder_mounted",
185 outcome = "advisory",
186 path = challenge::http_01::WELL_KNOWN_PREFIX,
187 stores = stores.len(),
188 "a reverse proxy must forward or redirect \
189 http://<identifier>:80/.well-known/acme-challenge/ here for the upstream to reach it"
190 );
191 root = root.merge(
192 Router::new()
193 .route(
194 &format!("{}{{token}}", challenge::http_01::WELL_KNOWN_PREFIX),
195 get(handlers::get_challenge_file),
196 )
197 .with_state(handlers::Http01Stores(Arc::new(stores))),
198 );
199 }
200
201 let mut acme = Router::new();
202 for profile in &profiles {
203 let path = profile.path.clone();
204 acme = acme.nest(
205 &path,
206 build_router(
207 database.clone(),
208 config.clone(),
209 profile.clone(),
210 audit.clone(),
211 jobs.clone(),
212 ),
213 );
214 }
215
216 let server = &config.server;
217 let acme = acme
218 .layer(middleware::from_fn_with_state(
219 middlewares::admission::Admission::new(
220 server.max_concurrent_requests,
221 server.admission_wait_ms,
222 server.request_timeout_ms,
223 ),
224 middlewares::admission::admission_middleware,
225 ))
226 // Innermost of the two, so it is in force by the time
227 // `String::from_request` reads the JWS body in `verify_jws`. Without it
228 // the ceiling is axum's implicit 2 MiB, which every concurrent request
229 // may buffer and then hand to `serde_json` — for a body that is a JWS
230 // carrying at most a CSR.
231 .layer(DefaultBodyLimit::max(server.max_body_bytes));
232
233 // Server-wide layers, applied once rather than once per profile. The
234 // filter and nonce layers are deliberately *not* here: both are ACME
235 // concerns and live inside each profile's own router.
236 let app = root.merge(acme);
237
238 // Innermost of the server-wide stack: a panic anywhere below here — a
239 // handler, the admission layer, a nested profile router — is turned into a
240 // 500 problem document instead of an aborted connection. Under the metrics
241 // and access layers on purpose, so the counter still sees `status = "500"`
242 // and the access line still emits (`request_completed`, with the profile
243 // span field already recorded). ASVS V16.5.4.
244 let app = app.layer(catch_panic_acme());
245
246 // Counting sits here even though the exposition is served on a *different*
247 // socket (see `metrics_app`): this is the only router that sees an ACME
248 // request, and the registry both share is an `Arc`. On the merged router
249 // rather than inside a profile, because `Router::layer` applies per route
250 // *and* to the fallback — so a request that matched nothing is counted too,
251 // under `ROUTE_UNMATCHED`. It also runs after routing, which is what makes
252 // `MatchedPath` present: the label has to be the route *pattern*
253 // (`/order/{id}`), never the URI, or every order ever finalized would be
254 // its own series for as long as the scraper retained it.
255 //
256 // Added only when the listener exists, so an operator who has not asked for
257 // metrics pays neither the lock nor the allocation per request.
258 let app = if config.metrics.enabled {
259 app.layer(middleware::from_fn_with_state(
260 metrics,
261 middlewares::metrics::record_request,
262 ))
263 } else {
264 app
265 };
266
267 app.layer(security_headers())
268 // Outermost of everything, so the `request` span it opens — and the
269 // `x-request-id` it echoes — covers every route, the admission layer
270 // and the two hardening layers alike. Nothing below it is allowed to
271 // log without an id.
272 .layer(middleware::from_fn(
273 middlewares::access::add_access_middleware,
274 ))
275}
276
277/// Builds the metrics listener's router: `GET /metrics` and nothing else.
278///
279/// A **third socket**, not a route on either of the other two. The port is the
280/// access control — see [`acme_proxy_core::config::MetricsConfig`] — which is why there
281/// is no session extractor here and no filter chain, and why the exposition can
282/// name every profile without that being a decision about the public listener.
283///
284/// Deliberately none of `build_app`'s layers. There is no admission control (a
285/// scrape is wanted *most* when the server is saturated, the reason `/health`
286/// sits outside it too), no `Replay-Nonce`, no `Link: rel="index"`, no
287/// `DefaultBodyLimit` (a `GET` with no body), and no security headers — those
288/// exist for a browser, and nothing renders this. It keeps only the access
289/// middleware, so a scrape is a `request_completed` line like everything else
290/// and its `x-request-id` correlates with whatever it was measuring.
291///
292/// This router is **not** behind a `reload` swap cell, unlike
293/// the other two. It has one route, and its only state is the registry — which
294/// by design is carried across generations rather than rebuilt (see
295/// `Assembly`), so there is nothing a reload could put in a
296/// new one. `metrics.enabled` and `metrics.bind_address` are frozen for the
297/// reason every bind address is: the socket cannot move under a running
298/// listener.
299pub fn metrics_app(metrics: Arc<metrics::Metrics>) -> Router {
300 Router::new()
301 .route("/metrics", get(handlers::get_metrics))
302 .with_state(handlers::MetricsState(metrics))
303 .layer(middleware::from_fn(
304 middlewares::access::add_access_middleware,
305 ))
306}
307
308/// Builds one profile's ACME router: every RFC 8555 resource, plus the two
309/// layers that are per-endpoint (its filter chain) or ACME-specific (the
310/// `Replay-Nonce` minting).
311///
312/// Paths here are relative to the mount point — `axum::Router::nest` strips
313/// the prefix before this router sees a request, which is also what makes
314/// `verify_jws`'s `base_url + path` reconstruction correct.
315pub fn build_router(
316 database: Arc<Database>,
317 config: Arc<Config>,
318 profile: Arc<Profile>,
319 audit: Arc<acme_proxy_jobs::auditor::Auditor>,
320 jobs: acme_proxy_jobs::jobs::JobQueue,
321) -> Router {
322 let filter = profile.filter.clone();
323 let state = AppState {
324 database: database.clone(),
325 config,
326 profile: profile.clone(),
327 audit,
328 jobs,
329 };
330
331 let profile_name = profile.name.clone();
332
333 // RFC 8555 §7.1 — the `index` link every resource but the directory carries.
334 // Built once here rather than per response; an invalid header value is
335 // impossible for a URL that already passed config validation, but falling
336 // back to skipping the layer beats panicking a whole endpoint over it.
337 let index_link =
338 HeaderValue::from_str(&format!("<{}/directory>;rel=\"index\"", profile.base_url));
339
340 let router = Router::<AppState>::new()
341 // §6.3: the directory and newNonce MUST answer a plain GET *and* a
342 // POST-as-GET. The extra methods chain onto one `MethodRouter` —
343 // registering the same path twice would replace the first route.
344 .route(
345 routes::DIRECTORY,
346 get(handlers::get_directory).post(handlers::post_directory),
347 )
348 .route(
349 routes::NEW_NONCE,
350 get(handlers::get_new_nonce)
351 .head(handlers::head_new_nonce)
352 .post(handlers::post_new_nonce),
353 )
354 .route(routes::NEW_ACCOUNT, post(handlers::post_new_account))
355 .route("/acct/{id}", post(handlers::post_account))
356 .route("/acct/{id}/orders", post(handlers::post_account_orders))
357 .route(routes::KEY_CHANGE, post(handlers::post_key_change))
358 .route(routes::NEW_ORDER, post(handlers::post_new_order))
359 .route("/order/{id}", post(handlers::post_order))
360 .route("/order/{id}/finalize", post(handlers::post_finalize))
361 .route("/authz/{id}", post(handlers::post_authz))
362 .route("/chall/{id}", post(handlers::post_challenge))
363 .route("/certificate/{id}", post(handlers::post_certificate))
364 .route(routes::REVOKE_CERT, post(handlers::post_revoke_cert))
365 .route(
366 &format!("{}/{{id}}", routes::RENEWAL_INFO),
367 get(handlers::get_renewal_info),
368 )
369 .route(routes::CRL, get(handlers::get_crl))
370 .route(routes::CA_CHAIN, get(handlers::get_ca_chain))
371 // §6.3: "if the server receives a GET request, it MUST return an error
372 // with status code 405 (Method Not Allowed) and type `malformed`".
373 // axum's own default gets the status right but sends an empty body, so
374 // these two fallbacks supply the problem document — for a wrong method
375 // and, in the same spirit, for a path that routes nowhere.
376 .method_not_allowed_fallback(|| async {
377 Problem::method_not_allowed("This resource must be read with POST-as-GET")
378 })
379 .fallback(|| async { Problem::not_found("No such resource") })
380 .with_state(state)
381 .layer(middleware::from_fn_with_state(
382 filter,
383 middlewares::filter::add_filter_middleware,
384 ))
385 .layer(middleware::from_fn_with_state(
386 database.clone(),
387 middlewares::nonce::add_nonce_middleware,
388 ));
389
390 // Outermost of the profile's layers that touch a response, so the link
391 // reaches every one of them — including the two fallbacks above and
392 // anything a filter refuses. (The `profile` recorder below wraps this, but
393 // only writes to the tracing span.)
394 let router = match index_link {
395 Ok(value) => router.layer(middleware::from_fn_with_state(
396 value,
397 middlewares::index_link::add_index_link_middleware,
398 )),
399 Err(error) => {
400 tracing::error!(
401 event = "request_index_link_header_invalid",
402 outcome = "failure",
403 base_url = %profile.base_url,
404 error = %error,
405 );
406 router
407 }
408 };
409
410 // `profile` is declared `field::Empty` on the server-wide `request` span
411 // (`middlewares::access`) and filled in here — the first layer that knows
412 // which endpoint the request landed on, since the name comes from the
413 // `/profile/<name>` mount point `Router::nest` has already stripped.
414 // Ahead of every other layer of this router so a request a filter refuses
415 // still says *which* endpoint refused it.
416 router.layer(middleware::from_fn(
417 move |request: Request<Body>, next: Next| {
418 let name = profile_name.clone();
419 async move {
420 Span::current().record("profile", &*name);
421 next.run(request).await
422 }
423 },
424 ))
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use axum::body::to_bytes;
431 use axum::http::StatusCode;
432 use axum::routing::get;
433 use tower::ServiceExt;
434
435 /// Every panic-payload shape resolves to a message; an odd one falls
436 /// back rather than panicking the panic handler.
437 #[test]
438 fn panic_message_covers_every_payload_shape() {
439 assert_eq!(panic_message(&"boom"), "boom");
440 assert_eq!(panic_message(&String::from("boom")), "boom");
441 assert_eq!(panic_message(&0u8), "a handler panicked");
442 }
443
444 /// `acme_panic_response` is a 500 problem document whatever the payload,
445 /// and the panic text never reaches the body.
446 #[tokio::test]
447 async fn acme_panic_response_is_a_problem_document() {
448 let response = acme_panic_response(Box::new("secret internal detail"));
449 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
450 assert_eq!(
451 response
452 .headers()
453 .get(header::CONTENT_TYPE)
454 .and_then(|v| v.to_str().ok()),
455 Some("application/problem+json"),
456 );
457 let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
458 let problem: serde_json::Value = serde_json::from_slice(&body).unwrap();
459 assert_eq!(problem["type"], "urn:ietf:params:acme:error:serverInternal");
460 assert_eq!(problem["status"], 500);
461 assert!(
462 !body_contains(&body, "secret internal detail"),
463 "the panic message must not reach the client",
464 );
465 }
466
467 fn body_contains(bytes: &[u8], needle: &str) -> bool {
468 std::str::from_utf8(bytes)
469 .map(|s| s.contains(needle))
470 .unwrap_or(false)
471 }
472
473 async fn boom() -> &'static str {
474 panic!("this handler panics on purpose")
475 }
476
477 fn app() -> Router {
478 Router::new()
479 .route("/ok", get(|| async { "ok" }))
480 .route("/boom", get(boom))
481 .layer(catch_panic_acme())
482 }
483
484 #[tokio::test]
485 async fn a_panicking_route_answers_a_problem_document() {
486 let response = app()
487 .oneshot(Request::get("/boom").body(Body::empty()).unwrap())
488 .await
489 .unwrap();
490 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
491 assert_eq!(
492 response
493 .headers()
494 .get(header::CONTENT_TYPE)
495 .and_then(|v| v.to_str().ok()),
496 Some("application/problem+json"),
497 );
498 }
499
500 #[tokio::test]
501 async fn the_layer_is_transparent_on_the_happy_path() {
502 let response = app()
503 .oneshot(Request::get("/ok").body(Body::empty()).unwrap())
504 .await
505 .unwrap();
506 assert_eq!(response.status(), StatusCode::OK);
507 }
508}