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}