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 coap;
19pub mod csrattrs;
20pub mod est;
21pub mod fullcmc;
22pub mod renewal_info;
23pub mod serverkeygen;
24pub mod simpleenroll;
25pub mod simplereenroll;
26pub mod star;
27
28use std::sync::Arc;
29
30use axum::Router;
31use axum::extract::{FromRef, FromRequestParts, Path};
32use axum::http::request::Parts;
33use axum::response::{IntoResponse, Redirect, Response};
34use axum::routing::{get, post};
35use tower_http::limit::RequestBodyLimitLayer;
36use tower_http::services::ServeDir;
37use tower_http::trace::TraceLayer;
38
39use crate::error::KipukaError;
40use crate::state::AppState;
41
42/// Build the complete Kipuka HTTP router.
43///
44/// # Route structure
45///
46/// ```text
47/// /.well-known/est/
48///     cacerts          GET   (§4.1)
49///     simpleenroll     POST  (§4.2)
50///     simplereenroll   POST  (§4.2.2)
51///     fullcmc          POST  (§4.3)
52///     serverkeygen     POST  (§4.4)
53///     csrattrs         GET   (§4.5)
54///
55/// /.well-known/est/{label}/
56///     (same endpoints as above, with per-label CA routing)
57///
58/// /admin/
59///     health           GET
60///     health/db        GET
61///     health/hsm       GET
62///     health/ca        GET
63///     cas              GET
64///     cas/{id}         GET
65///     cas/{id}/health  GET
66///     otp/generate     POST
67///     otp              GET
68///     otp/{id}         DELETE
69///     certs            GET
70///     certs/{serial}   GET
71///     certs/{serial}/revoke POST
72///
73/// /.well-known/est/star/
74///     POST                  Create STAR order (RFC 8739)
75///     {order_id}       GET  Fetch current certificate
76///     {order_id}       DELETE Cancel STAR order
77///     {order_id}/history GET List certificate series
78/// ```
79pub fn build_router(state: Arc<AppState>) -> Router {
80    let max_body = state.config.server.max_body_size;
81
82    // EST routes for the default label.
83    let est_routes = est::est_router();
84
85    // Per-label EST routes: /.well-known/est/{label}/
86    let labeled_est_routes = Router::new().nest("/{label}", est::est_router());
87
88    // Admin routes with separate authentication.
89    let admin_routes = if state
90        .config
91        .admin
92        .as_ref()
93        .is_some_and(|c| c.enabled && c.listen_addr.is_none())
94    {
95        admin::admin_router()
96    } else {
97        Router::new()
98    };
99
100    Router::new()
101        .nest("/.well-known/est", est_routes)
102        .nest("/.well-known/est", labeled_est_routes)
103        .nest("/admin", admin_routes)
104        // CMS-wrapped EST routes (RFC 8295) — disabled pending synta-cmc API update
105
106        // STAR certificate routes (RFC 8739).
107        .nest("/.well-known/est/star", star::star_router())
108        // Renewal info (draft-ietf-lamps-est-renewal-info).
109        // Intentionally label-independent: the cert_id (AKI + serial) uniquely
110        // identifies a certificate regardless of which EST label was used for
111        // enrollment.  Per-label renewal-info can be added later if needed.
112        .route(
113            "/.well-known/est/renewal-info/{cert_id}",
114            get(renewal_info::get_renewal_info),
115        )
116        // CMP v3 endpoint (RFC 9810).
117        .route("/.well-known/cmp", post(cmp::post_cmp))
118        // Web dashboard (static files).
119        .route("/", get(|| async { Redirect::permanent("/dashboard/") }))
120        .nest_service(
121            "/dashboard",
122            ServeDir::new("/var/www/kipuka/web").append_index_html_on_directories(true),
123        )
124        .layer(axum::middleware::from_fn(crate::auth::gssapi::response_token))
125        .layer(RequestBodyLimitLayer::new(max_body))
126        .layer(
127            TraceLayer::new_for_http()
128                .make_span_with(|req: &axum::http::Request<_>| {
129                    tracing::info_span!(
130                        "http_request",
131                        method = %req.method(),
132                        path = %req.uri().path(),
133                        version = ?req.version(),
134                    )
135                }),
136        )
137        .with_state(state)
138}
139
140// ── Label extractor ──────────────────────────────────────────────────────────
141
142/// Resolved EST label configuration for the current request.
143///
144/// Analogous to Akamu's `CaId` extractor — resolves the `{label}` path
145/// segment to the corresponding `EstLabelConfig` entry, falling back
146/// to the default label when no path segment is present.
147///
148/// # Usage
149///
150/// ```rust,ignore
151/// async fn handler(label: LabelExtractor, ...) -> impl IntoResponse {
152///     let ca_id = label.ca_id();
153///     // ...
154/// }
155/// ```
156#[derive(Debug, Clone)]
157pub struct LabelExtractor {
158    /// The resolved label name (empty string for the default label).
159    pub label: String,
160    /// The CA identifier to use for this label.
161    pub ca_id: String,
162    /// Whether CN matching is required for this label (FDP_ACF.1).
163    pub require_cn_match: bool,
164    /// Whether the authenticated identity must appear in the CSR SAN (FDP_ACF.1).
165    pub require_san_match: bool,
166    /// Name-authorization allowlist: permitted dNSName patterns (RFC 6125).
167    pub permitted_dns_names: Vec<String>,
168    /// Name-authorization allowlist: permitted iPAddress SANs (textual form).
169    pub permitted_ip_addresses: Vec<String>,
170    /// Name-authorization allowlist: permitted rfc822Name patterns.
171    pub permitted_emails: Vec<String>,
172    /// Per-label CSR attribute OIDs (overrides global when non-empty).
173    pub csr_attributes: Vec<String>,
174    /// Per-label CSR template (RFC 9908); `None` means no template.
175    pub csr_template: Option<crate::config::CsrTemplate>,
176    /// Per-label maximum validity (overrides CA default).
177    pub max_validity_days: Option<u32>,
178    /// Per-label disconnected mode override.
179    pub disconnected: Option<bool>,
180}
181
182impl LabelExtractor {
183    /// The effective CA identifier for this label.
184    pub fn ca_id(&self) -> &str {
185        &self.ca_id
186    }
187
188    /// Borrow this label's enrollment-authorization policy (FDP_ACF.1).
189    ///
190    /// Single source of truth for every enrollment entry point — the direct
191    /// `/simpleenroll` path and the CMS-EST wrappers all build the policy this
192    /// way, so identity binding and name authorization stay consistent across
193    /// transports.
194    pub fn enroll_policy(&self) -> crate::auth::enroll_authz::EnrollAuthzPolicy<'_> {
195        crate::auth::enroll_authz::EnrollAuthzPolicy {
196            require_cn_match: self.require_cn_match,
197            require_san_match: self.require_san_match,
198            permitted_dns_names: &self.permitted_dns_names,
199            permitted_ip_addresses: &self.permitted_ip_addresses,
200            permitted_emails: &self.permitted_emails,
201        }
202    }
203
204    /// Resolve a label name into its effective enrollment configuration.
205    ///
206    /// This is the transport-agnostic core shared by the axum
207    /// [`FromRequestParts`] extractor (HTTP/CMS-EST) and the CoAP bridge
208    /// ([`crate::routes::coap`]).  Centralising it here means the per-label
209    /// FDP_ACF.1 policy — identity binding and the name allowlist — resolves
210    /// identically no matter which transport carried the request, closing the
211    /// gap where EST-coaps issued certificates without applying any label
212    /// policy at all.
213    ///
214    /// `label_name` is `None`/`Some("")` for the default (unlabeled) endpoint,
215    /// which carries no authorization policy and issues from the default CA.
216    ///
217    /// # Errors
218    ///
219    /// - [`KipukaError::NotFound`] when a non-empty label is not configured.
220    /// - [`KipukaError::Config`] when a configured label points at a CA that
221    ///   does not exist.
222    pub fn resolve(app: &AppState, label_name: Option<&str>) -> Result<Self, KipukaError> {
223        let est_config = &app.config.est;
224
225        match label_name {
226            Some(name) if !name.is_empty() => {
227                // Look up the label in the configured labels.
228                let label_config = est_config
229                    .labels
230                    .iter()
231                    .find(|l| l.name == name)
232                    .ok_or_else(|| {
233                        tracing::debug!(label = %name, "unknown EST label");
234                        KipukaError::NotFound
235                    })?;
236
237                // Resolve the CA ID: label-specific or default.
238                let ca_id = if label_config.ca_pool.is_empty() {
239                    label_config
240                        .ca_id
241                        .clone()
242                        .unwrap_or_else(|| (*app.default_ca_id).clone())
243                } else {
244                    app.ha_manager
245                        .as_ref()
246                        .and_then(|manager| manager.pool().select_allowed(&label_config.ca_pool))
247                        .map(|ca| ca.id.0)
248                        .ok_or_else(|| {
249                            KipukaError::ServiceUnavailable("no healthy CA in label pool".into())
250                        })?
251                };
252
253                // Verify the CA exists.
254                if app.get_ca(&ca_id).is_none() {
255                    tracing::error!(
256                        label = %name,
257                        ca_id = %ca_id,
258                        "label references unknown CA"
259                    );
260                    return Err(KipukaError::Config(format!(
261                        "label {name:?} references unknown CA {ca_id:?}"
262                    )));
263                }
264
265                Ok(LabelExtractor {
266                    label: name.to_string(),
267                    ca_id,
268                    require_cn_match: label_config.require_cn_match,
269                    require_san_match: label_config.require_san_match,
270                    permitted_dns_names: label_config.permitted_dns_names.clone(),
271                    permitted_ip_addresses: label_config.permitted_ip_addresses.clone(),
272                    permitted_emails: label_config.permitted_emails.clone(),
273                    csr_attributes: label_config.csr_attributes.clone(),
274                    csr_template: label_config.csr_template.clone(),
275                    max_validity_days: label_config.max_validity_days,
276                    disconnected: label_config.disconnected,
277                })
278            }
279            _ => {
280                // Default label — use the default CA.  No per-label policy.
281                Ok(LabelExtractor {
282                    label: String::new(),
283                    ca_id: (*app.default_ca_id).clone(),
284                    require_cn_match: false,
285                    require_san_match: false,
286                    permitted_dns_names: Vec::new(),
287                    permitted_ip_addresses: Vec::new(),
288                    permitted_emails: Vec::new(),
289                    csr_attributes: est_config.csr_attributes.clone(),
290                    csr_template: est_config.csr_template.clone(),
291                    max_validity_days: None,
292                    disconnected: None,
293                })
294            }
295        }
296    }
297}
298
299impl<S> FromRequestParts<S> for LabelExtractor
300where
301    S: Send + Sync,
302    Arc<AppState>: FromRef<S>,
303{
304    type Rejection = Response;
305
306    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Response> {
307        if let Some(label) = parts.extensions.get::<LabelExtractor>() {
308            return Ok(label.clone());
309        }
310        let app = Arc::<AppState>::from_ref(state);
311
312        // Try to extract the {label} path parameter.
313        let label_name: Option<String> =
314            Path::<std::collections::HashMap<String, String>>::from_request_parts(parts, state)
315                .await
316                .ok()
317                .and_then(|Path(params)| params.get("label").cloned());
318
319        // Delegate to the transport-agnostic resolver so HTTP and CoAP share
320        // one label-resolution path (and thus one FDP_ACF.1 policy source).
321        let label =
322            Self::resolve(&app, label_name.as_deref()).map_err(IntoResponse::into_response)?;
323        parts.extensions.insert(label.clone());
324        Ok(label)
325    }
326}
327
328/// Dedicated management listener: excludes all EST enrollment routes.
329pub fn build_admin_router(state: Arc<AppState>) -> Router {
330    Router::new()
331        .nest("/admin", admin::admin_router())
332        .layer(RequestBodyLimitLayer::new(
333            state.config.server.max_body_size,
334        ))
335        .with_state(state)
336}