Skip to main content

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}