crates/gunnar-ui-server/src/front_certs.rs
//! **The front's certificates on the Administration page** (owner 2026-10-25: certificates
//! must fix themselves). Thin: the FRONT holds and decides everything (gunnar-front's
//! `web::status`, ported from edda estate-front 0.1.4: valid until, next attempt, last reason,
//! try now's rule, the one-hour alert); this server only passes it on, to an admin.
//!
//! ```text
//! GET /api/front/certs → {certificates: [{host, hosts, valid_until, next_attempt, last_reason, lines, try_now, alert, …}]}
//! POST /api/front/certs/try → {words} — {host}: the front's try now (409 with its refusal)
//! ```
//!
//! Both are an admin's (`ADMIN_READS`, `ADMIN_SURFACES`). The front's half is a
//! [`FrontCerts`] the binary serving the console installs at its start ([`install`]); a
//! console with none (not served by a front) answers 404 with that said.
use std::sync::{Arc, Mutex};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
/// The routes.
pub const CERTS_PATH: &str = "/api/front/certs";
pub const TRY_PATH: &str = "/api/front/certs/try";
/// **The front's half**: its report, and an admin's try now.
pub trait FrontCerts: Send + Sync {
fn report(&self) -> serde_json::Value;
fn try_now(&self, host: &str) -> Result<String, String>;
}
static FRONT: Mutex<Option<Arc<dyn FrontCerts>>> = Mutex::new(None);
/// Install the front's half (the front's console start; a test's fake).
pub fn install(f: Arc<dyn FrontCerts>) {
*FRONT.lock().unwrap_or_else(|p| p.into_inner()) = Some(f);
}
fn the_front() -> Option<Arc<dyn FrontCerts>> {
FRONT.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
fn no_front() -> Response {
(StatusCode::NOT_FOUND, "this console is not served by an estate front: it has no certificates of its own to show").into_response()
}
/// `GET /api/front/certs`.
pub async fn certs() -> Response {
let Some(f) = the_front() else { return no_front() };
match tokio::task::spawn_blocking(move || f.report()).await {
Ok(v) => Json(v).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("the front's report: {e}")).into_response(),
}
}
/// The body of `POST /api/front/certs/try`.
#[derive(Debug, serde::Deserialize)]
pub struct TryCert {
pub host: String,
}
/// `POST /api/front/certs/try` — the front decides (409 with its refusal).
pub async fn try_cert(Json(t): Json<TryCert>) -> Response {
let Some(f) = the_front() else { return no_front() };
let host = t.host.trim().to_ascii_lowercase();
match tokio::task::spawn_blocking(move || f.try_now(&host)).await {
Ok(Ok(words)) => Json(serde_json::json!({ "words": words })).into_response(),
Ok(Err(e)) => (StatusCode::CONFLICT, e).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("the front's try now: {e}")).into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tower::ServiceExt as _;
struct Fake;
impl FrontCerts for Fake {
fn report(&self) -> serde_json::Value {
serde_json::json!({"certificates": [{"host": "gunnar.example", "lines": ["valid until: no valid certificate"], "try_now": true}]})
}
fn try_now(&self, host: &str) -> Result<String, String> {
if host == "v.example" {
Err("REFUSED [tls-try-now-validation] the last ACME attempt failed validation".into())
} else {
Ok(format!("tls: {host} is looked at now"))
}
}
}
async fn call(method: &str, path: &str, body: &str) -> (StatusCode, String) {
let r = axum::Router::new().route(CERTS_PATH, axum::routing::get(certs)).route(TRY_PATH, axum::routing::post(try_cert));
let req = axum::http::Request::builder().method(method).uri(path).header("content-type", "application/json").body(axum::body::Body::from(body.to_string())).unwrap();
let res = r.oneshot(req).await.unwrap();
let st = res.status();
let b = axum::body::to_bytes(res.into_body(), 1 << 20).await.unwrap();
(st, String::from_utf8_lossy(&b).into_owned())
}
/// ★ The front's report passed on as it is; its try now and its refusal in its words.
#[tokio::test]
async fn the_fronts_certificates_are_passed_on_as_the_front_says_them() {
install(Arc::new(Fake));
let (st, text) = call("GET", CERTS_PATH, "").await;
assert_eq!(st, StatusCode::OK, "{text}");
assert!(text.contains("gunnar.example") && text.contains("no valid certificate"), "{text}");
let (st, text) = call("POST", TRY_PATH, r#"{"host":"gunnar.example"}"#).await;
assert_eq!(st, StatusCode::OK, "{text}");
assert!(text.contains("is looked at now"), "{text}");
let (st, text) = call("POST", TRY_PATH, r#"{"host":"v.example"}"#).await;
assert_eq!(st, StatusCode::CONFLICT, "{text}");
assert!(text.contains("tls-try-now-validation"), "{text}");
}
}