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}