Skip to main content

hoike_server/
lib.rs

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
12/// Announce every CA scope in a freshly produced bundle to the gossip mesh.
13///
14/// Parses the sealed bundle bytes to recover each scope's `producer_id`,
15/// `issuer_key_hash`, and `epoch`, plus the manifest index digest used as the
16/// generation identifier, then broadcasts one `GenerationAnnouncement` per
17/// scope. Failures are logged, never fatal — gossip is best-effort and must not
18/// break a successful signing pass. Centralized here because this is the only
19/// crate that depends on both `ahu` (to parse) and `hoike-gossip` (to send).
20pub 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
50/// Install the global Prometheus recorder. No-op (returns `false`) unless the
51/// `metrics` feature is enabled. Call once at startup before serving.
52pub fn install_metrics() -> bool {
53    obs::install()
54}
55
56/// Build a minimal router exposing `GET /metrics` in Prometheus text format.
57///
58/// Kept separate from the main OCSP router so operators bind it on a private
59/// listener (`server.metrics_listen`), never the public OCSP port.
60pub 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    // Collect-on-scrape: refresh the freshness gauges from the loaded scopes so
68    // age/next-update reflect the scrape-time clock, not the last request.
69    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    // Census the gossip fleet on scrape, if a node is attached.
76    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
105/// Build the public OCSP router.
106///
107/// When no dedicated admin listener is configured (`server.admin_listen`
108/// unset), the admin API and web UI are nested onto this same router for
109/// backward compatibility — the legacy single-port layout. When `admin_listen`
110/// *is* set, the management surface moves to its own listener
111/// (`build_admin_router`) so it can be given a trusted path (TLS, FTP_TRP.1)
112/// without wrapping the plaintext OCSP data plane; this router then serves OCSP
113/// only.
114pub 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    // Legacy layout: admin + UI ride the OCSP port only when no dedicated admin
123    // listener is configured.
124    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
133/// Build the standalone admin router (admin API + web UI) for a dedicated
134/// management listener. Contains no OCSP routes, so it can be TLS-terminated
135/// independently of the plaintext OCSP data plane.
136pub fn build_admin_router_standalone(state: AppState) -> Router {
137    // `admin::build_admin_router` already applies `.with_state`, yielding a
138    // fully-stated `Router<()>`; nesting it needs no further state.
139    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
144/// Mount the web UI (disk-served `static_dir`, or the embedded SPA) onto a
145/// router. Shared by the legacy single-port layout and the standalone admin
146/// router so both surface the UI identically.
147fn 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    /// Mount the embedded SPA on the main router.
188    ///
189    /// The OCSP protocol occupies a greedy `GET /{*path}` catch-all (OCSP-over-GET
190    /// carries a base64 request in the path). Nesting a sub-router for the SPA is
191    /// unreliable under axum 0.8: `GET /ui/` (the directory root, trailing slash)
192    /// loses to the catch-all and returns the 5-byte OCSP `malformedRequest` error.
193    /// Registering explicit static routes — which outrank the wildcard in matchit —
194    /// keeps the SPA entry point (`/ui`, `/ui/`) and its assets (`/ui/{*path}`)
195    /// deterministically routed. `/ui` and `/ui/` redirect to the app index.
196    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; // for `oneshot`
262
263    /// Reproduce the production route layout: the SPA is mounted alongside the
264    /// greedy OCSP-over-GET catch-all (`GET /{*path}`). This guards against two
265    /// regressions: (1) a matchit wildcard-conflict panic at router-build time,
266    /// and (2) `GET /ui/` falling through to the OCSP handler (which returns the
267    /// 5-byte `malformedRequest` DER, not the SPA).
268    fn test_router() -> Router {
269        async fn ocsp_catch_all() -> &'static [u8] {
270            // The static OCSP malformedRequest response is 5 bytes.
271            &[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        // A non-/ui path must still reach the OCSP catch-all unchanged.
312        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}