1mod admin;
2mod handlers;
3pub mod obs;
4mod state;
5#[cfg(feature = "tls")]
6pub mod tls;
7
8pub use state::{AppState, LiveSignerState, SignerContext};
9
10use axum::Router;
11
12pub async fn announce_bundle_scopes(gossip: &hoike_gossip::GossipNode, bytes: &[u8]) {
21 let bundle = match ahu::Bundle::from_bytes(bytes) {
22 Ok(b) => b,
23 Err(e) => {
24 tracing::warn!(error = %e, "gossip announce: failed to parse produced bundle");
25 return;
26 }
27 };
28 let producer_id = bundle.manifest.producer_id.clone();
29 let manifest_digest = bundle.manifest.integrity.index_digest;
30 for scope in &bundle.manifest.ca_scopes {
31 if let Err(e) = gossip
32 .announce_generation(
33 producer_id.clone(),
34 scope.issuer_key_hash.clone(),
35 scope.epoch,
36 manifest_digest,
37 None,
38 )
39 .await
40 {
41 tracing::warn!(error = %e, epoch = scope.epoch, "gossip announce failed");
42 }
43 }
44}
45use axum::extract::State;
46use axum::http::{StatusCode, header};
47use axum::response::{IntoResponse, Response};
48use axum::routing::{get, post};
49
50pub fn install_metrics() -> bool {
53 obs::install()
54}
55
56pub fn build_metrics_router(state: AppState) -> Router {
61 Router::new()
62 .route("/metrics", get(metrics_handler))
63 .with_state(state)
64}
65
66async fn metrics_handler(State(state): State<AppState>) -> Response {
67 let now = std::time::SystemTime::now()
70 .duration_since(std::time::UNIX_EPOCH)
71 .unwrap_or_default()
72 .as_secs();
73 obs::update_bundle_gauges(&state.responder.bundle_scopes(), now);
74
75 if let Some(gossip) = state.gossip.as_ref() {
77 use hoike_gossip::node::MemberState;
78 let members = gossip.members().await;
79 let (mut alive, mut suspect, mut down) = (0u64, 0u64, 0u64);
80 for m in &members {
81 match m.state {
82 MemberState::Alive => alive += 1,
83 MemberState::Suspect => suspect += 1,
84 MemberState::Down => down += 1,
85 }
86 }
87 obs::record_gossip_members(alive, suspect, down);
88 }
89
90 match obs::render() {
91 Some(body) => (
92 StatusCode::OK,
93 [(header::CONTENT_TYPE, "text/plain; version=0.0.4")],
94 body,
95 )
96 .into_response(),
97 None => (
98 StatusCode::SERVICE_UNAVAILABLE,
99 "metrics not enabled (build with --features metrics)",
100 )
101 .into_response(),
102 }
103}
104
105pub fn build_router(state: AppState) -> Router {
115 let mut app = Router::new()
116 .route("/", post(handlers::handle_post))
117 .route("/", get(handlers::handle_get_root))
118 .route("/{*path}", get(handlers::handle_get))
119 .route("/{*path}", post(handlers::handle_post))
120 .with_state(state.clone());
121
122 if state.admin.config.server.admin_listen.is_none() {
125 let admin_router = admin::build_admin_router(state.clone());
126 app = app.nest("/api/admin", admin_router);
127 app = mount_webui(app, &state);
128 }
129
130 app
131}
132
133pub fn build_admin_router_standalone(state: AppState) -> Router {
137 let admin_router = admin::build_admin_router(state.clone());
140 let app = Router::new().nest("/api/admin", admin_router);
141 mount_webui(app, &state)
142}
143
144fn mount_webui(mut app: Router, state: &AppState) -> Router {
148 if let Some(webui_config) = &state.admin.config.server.webui {
149 if let Some(static_dir) = &webui_config.static_dir {
150 let serve = tower_http::services::ServeDir::new(static_dir).fallback(
151 tower_http::services::ServeFile::new(
152 std::path::Path::new(static_dir).join("index.html"),
153 ),
154 );
155 app = app.nest_service("/ui", serve);
156 }
157 }
158
159 #[cfg(feature = "embed-webui")]
160 {
161 if state
162 .admin
163 .config
164 .server
165 .webui
166 .as_ref()
167 .is_some_and(|w| w.static_dir.is_none())
168 {
169 app = embedded_ui::mount(app);
170 }
171 }
172
173 app
174}
175
176#[cfg(feature = "embed-webui")]
177mod embedded_ui {
178 use axum::Router;
179 use axum::extract::Path;
180 use axum::http::{StatusCode, header};
181 use axum::response::{IntoResponse, Redirect};
182 use axum::routing::get;
183 use include_dir::{Dir, include_dir};
184
185 static WEBUI_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../../webui/dist");
186
187 pub fn mount<S>(app: Router<S>) -> Router<S>
197 where
198 S: Clone + Send + Sync + 'static,
199 {
200 app.route("/ui", get(|| async { Redirect::permanent("/ui/") }))
201 .route("/ui/", get(serve_index))
202 .route("/ui/{*path}", get(serve_file))
203 }
204
205 async fn serve_index() -> axum::response::Response {
206 serve_path("index.html")
207 }
208
209 async fn serve_file(Path(path): Path<String>) -> axum::response::Response {
210 serve_path(&path)
211 }
212
213 fn serve_path(path: &str) -> axum::response::Response {
214 match WEBUI_DIR.get_file(path) {
215 Some(file) => {
216 let mime = mime_from_path(path);
217 (
218 StatusCode::OK,
219 [(header::CONTENT_TYPE, mime)],
220 file.contents().to_vec(),
221 )
222 .into_response()
223 }
224 None => {
225 if let Some(index) = WEBUI_DIR.get_file("index.html") {
226 (
227 StatusCode::OK,
228 [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
229 index.contents().to_vec(),
230 )
231 .into_response()
232 } else {
233 StatusCode::NOT_FOUND.into_response()
234 }
235 }
236 }
237 }
238
239 fn mime_from_path(path: &str) -> &'static str {
240 match path.rsplit('.').next() {
241 Some("html") => "text/html; charset=utf-8",
242 Some("js") => "application/javascript; charset=utf-8",
243 Some("css") => "text/css; charset=utf-8",
244 Some("json") => "application/json",
245 Some("svg") => "image/svg+xml",
246 Some("png") => "image/png",
247 Some("ico") => "image/x-icon",
248 Some("woff2") => "font/woff2",
249 Some("woff") => "font/woff",
250 _ => "application/octet-stream",
251 }
252 }
253}
254
255#[cfg(all(test, feature = "embed-webui"))]
256mod embedded_ui_tests {
257 use axum::Router;
258 use axum::body::Body;
259 use axum::http::{Request, StatusCode, header};
260 use axum::routing::get;
261 use tower::ServiceExt; fn test_router() -> Router {
269 async fn ocsp_catch_all() -> &'static [u8] {
270 &[0x30, 0x03, 0x0a, 0x01, 0x01]
272 }
273 let app = Router::new().route("/{*path}", get(ocsp_catch_all));
274 super::embedded_ui::mount(app)
275 }
276
277 #[tokio::test]
278 async fn ui_root_serves_spa_not_ocsp() {
279 let resp = test_router()
280 .oneshot(Request::get("/ui/").body(Body::empty()).unwrap())
281 .await
282 .unwrap();
283 assert_eq!(resp.status(), StatusCode::OK);
284 let ctype = resp
285 .headers()
286 .get(header::CONTENT_TYPE)
287 .and_then(|v| v.to_str().ok())
288 .unwrap_or("");
289 assert!(
290 ctype.starts_with("text/html"),
291 "GET /ui/ must serve the SPA index (text/html), got content-type {ctype:?}"
292 );
293 }
294
295 #[tokio::test]
296 async fn ui_bare_redirects_to_slash() {
297 let resp = test_router()
298 .oneshot(Request::get("/ui").body(Body::empty()).unwrap())
299 .await
300 .unwrap();
301 assert_eq!(resp.status(), StatusCode::PERMANENT_REDIRECT);
302 assert_eq!(
303 resp.headers().get(header::LOCATION).unwrap(),
304 "/ui/",
305 "GET /ui must redirect to /ui/"
306 );
307 }
308
309 #[tokio::test]
310 async fn non_ui_path_still_hits_ocsp() {
311 let resp = test_router()
313 .oneshot(Request::get("/MFQwUj...").body(Body::empty()).unwrap())
314 .await
315 .unwrap();
316 assert_eq!(resp.status(), StatusCode::OK);
317 let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap();
318 assert_eq!(body.len(), 5, "OCSP-over-GET path must still be served");
319 }
320}