Skip to main content

kipuka/routes/
mod.rs

1//! HTTP routing for the Kipuka EST server.
2//!
3//! Builds the main axum [`Router`] with three route groups:
4//!
5//! 1. **EST endpoints** under `/.well-known/est/` (RFC 5785 + RFC 7030)
6//! 2. **Per-label EST endpoints** under `/.well-known/est/{label}/` (RFC 7030 §3.2.2)
7//! 3. **Admin API** under `/admin/` with separate authentication
8//!
9//! Middleware applied at the router level:
10//! - Body size limit (`[server].max_body_size`, default 64 KiB)
11//! - Request tracing (tracing spans per request)
12//! - Audit logging for enrollment and admin operations
13
14pub mod admin;
15pub mod cacerts;
16pub mod cmp;
17pub mod cms_est;
18pub mod csrattrs;
19pub mod est;
20pub mod fullcmc;
21pub mod serverkeygen;
22pub mod simpleenroll;
23pub mod simplereenroll;
24pub mod star;
25
26use std::sync::Arc;
27
28use axum::Router;
29use axum::extract::{FromRef, FromRequestParts, Path};
30use axum::http::request::Parts;
31use axum::response::{IntoResponse, Redirect, Response};
32use axum::routing::{get, post};
33use tower_http::limit::RequestBodyLimitLayer;
34use tower_http::services::ServeDir;
35use tower_http::trace::TraceLayer;
36
37use crate::error::KipukaError;
38use crate::state::AppState;
39
40/// Build the complete Kipuka HTTP router.
41///
42/// # Route structure
43///
44/// ```text
45/// /.well-known/est/
46///     cacerts          GET   (§4.1)
47///     simpleenroll     POST  (§4.2)
48///     simplereenroll   POST  (§4.2.2)
49///     fullcmc          POST  (§4.3)
50///     serverkeygen     POST  (§4.4)
51///     csrattrs         GET   (§4.5)
52///
53/// /.well-known/est/{label}/
54///     (same endpoints as above, with per-label CA routing)
55///
56/// /admin/
57///     health           GET
58///     health/db        GET
59///     health/hsm       GET
60///     health/ca        GET
61///     cas              GET
62///     cas/{id}         GET
63///     cas/{id}/health  GET
64///     otp/generate     POST
65///     otp              GET
66///     otp/{id}         DELETE
67///     certs            GET
68///     certs/{serial}   GET
69///     certs/{serial}/revoke POST
70///
71/// /.well-known/est/star/
72///     POST                  Create STAR order (RFC 8739)
73///     {order_id}       GET  Fetch current certificate
74///     {order_id}       DELETE Cancel STAR order
75///     {order_id}/history GET List certificate series
76/// ```
77pub fn build_router(state: Arc<AppState>) -> Router {
78    let max_body = state.config.server.max_body_size;
79
80    // EST routes for the default label.
81    let est_routes = est::est_router();
82
83    // Per-label EST routes: /.well-known/est/{label}/
84    let labeled_est_routes = Router::new().nest("/{label}", est::est_router());
85
86    // Admin routes with separate authentication.
87    let admin_routes = admin::admin_router();
88
89    Router::new()
90        .nest("/.well-known/est", est_routes)
91        .nest("/.well-known/est", labeled_est_routes)
92        .nest("/admin", admin_routes)
93        // CMS-wrapped EST routes (RFC 8295).
94        .nest("/.well-known/est/cms", cms_est::cms_est_router())
95        // STAR certificate routes (RFC 8739).
96        .nest("/.well-known/est/star", star::star_router())
97        // CMP v3 endpoint (RFC 9810).
98        .route("/.well-known/cmp", post(cmp::post_cmp))
99        // Web dashboard (static files).
100        .route("/", get(|| async { Redirect::permanent("/dashboard/") }))
101        .nest_service(
102            "/dashboard",
103            ServeDir::new("/var/www/kipuka/web").append_index_html_on_directories(true),
104        )
105        .layer(RequestBodyLimitLayer::new(max_body))
106        .layer(
107            TraceLayer::new_for_http()
108                .make_span_with(|req: &axum::http::Request<_>| {
109                    tracing::info_span!(
110                        "http_request",
111                        method = %req.method(),
112                        path = %req.uri().path(),
113                        version = ?req.version(),
114                    )
115                }),
116        )
117        .with_state(state)
118}
119
120// ── Label extractor ──────────────────────────────────────────────────────────
121
122/// Resolved EST label configuration for the current request.
123///
124/// Analogous to Akamu's `CaId` extractor — resolves the `{label}` path
125/// segment to the corresponding [`EstLabelConfig`] entry, falling back
126/// to the default label when no path segment is present.
127///
128/// # Usage
129///
130/// ```rust,ignore
131/// async fn handler(label: LabelExtractor, ...) -> impl IntoResponse {
132///     let ca_id = label.ca_id();
133///     // ...
134/// }
135/// ```
136#[derive(Debug, Clone)]
137pub struct LabelExtractor {
138    /// The resolved label name (empty string for the default label).
139    pub label: String,
140    /// The CA identifier to use for this label.
141    pub ca_id: String,
142    /// Whether CN matching is required for this label.
143    pub require_cn_match: bool,
144    /// Per-label CSR attribute OIDs (overrides global when non-empty).
145    pub csr_attributes: Vec<String>,
146    /// Per-label maximum validity (overrides CA default).
147    pub max_validity_days: Option<u32>,
148    /// Per-label disconnected mode override.
149    pub disconnected: Option<bool>,
150}
151
152impl LabelExtractor {
153    /// The effective CA identifier for this label.
154    pub fn ca_id(&self) -> &str {
155        &self.ca_id
156    }
157}
158
159impl<S> FromRequestParts<S> for LabelExtractor
160where
161    S: Send + Sync,
162    Arc<AppState>: FromRef<S>,
163{
164    type Rejection = Response;
165
166    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Response> {
167        let app = Arc::<AppState>::from_ref(state);
168
169        // Try to extract the {label} path parameter.
170        let label_name: Option<String> = Path::<String>::from_request_parts(parts, state)
171            .await
172            .ok()
173            .map(|Path(l)| l);
174
175        let est_config = &app.config.est;
176
177        match label_name {
178            Some(ref name) if !name.is_empty() => {
179                // Look up the label in the configured labels.
180                let label_config = est_config
181                    .labels
182                    .iter()
183                    .find(|l| l.name == *name)
184                    .ok_or_else(|| {
185                        tracing::debug!(label = %name, "unknown EST label");
186                        KipukaError::NotFound.into_response()
187                    })?;
188
189                // Resolve the CA ID: label-specific or default.
190                let ca_id = label_config
191                    .ca_id
192                    .clone()
193                    .unwrap_or_else(|| (*app.default_ca_id).clone());
194
195                // Verify the CA exists.
196                if app.get_ca(&ca_id).is_none() {
197                    tracing::error!(
198                        label = %name,
199                        ca_id = %ca_id,
200                        "label references unknown CA"
201                    );
202                    return Err(KipukaError::Config(format!(
203                        "label {name:?} references unknown CA {ca_id:?}"
204                    ))
205                    .into_response());
206                }
207
208                Ok(LabelExtractor {
209                    label: name.clone(),
210                    ca_id,
211                    require_cn_match: label_config.require_cn_match,
212                    csr_attributes: label_config.csr_attributes.clone(),
213                    max_validity_days: label_config.max_validity_days,
214                    disconnected: label_config.disconnected,
215                })
216            }
217            _ => {
218                // Default label — use the default CA.
219                let ca_id = (*app.default_ca_id).clone();
220
221                Ok(LabelExtractor {
222                    label: String::new(),
223                    ca_id,
224                    require_cn_match: false,
225                    csr_attributes: est_config.csr_attributes.clone(),
226                    max_validity_days: None,
227                    disconnected: None,
228                })
229            }
230        }
231    }
232}