holger-estate-front/src/certs_page.rs
//! **This front's certificates, for holger-ui's admin page** (owner 2026-10-25: certificates
//! must fix themselves). Thin: every word is edda estate-front's `web::status` (valid until,
//! next attempt, last reason, try now, the one-hour alert), read from the data disk this
//! front's web half writes.
//!
//! ```text
//! GET /front/certs → {certificates: [{host, hosts, valid_until, next_attempt, last_reason, lines, try_now, alert, …}]}
//! POST /front/certs/try → {words} — {host}: "try now" (refused while a failed validation's wait runs)
//! ```
//!
//! Both are an ADMIN's: the front holds no session of its own, so it asks the primary who
//! the caller is (`AdminService.WhoAmI` with the caller's own `Authorization`) and answers
//! only a global `admin`. Not under `/-/`, which is the primary's.
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
/// The routes.
pub const CERTS_PATH: &str = "/front/certs";
pub const TRY_PATH: &str = "/front/certs/try";
/// **Who is asking**: the caller's global role as the primary says it (`""`: nobody).
pub type Roles = Arc<
dyn Fn(Option<String>) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>>
+ Send
+ Sync,
>;
/// The live [`Roles`]: `AdminService.WhoAmI` on the primary's gRPC door, the caller's own
/// `Authorization` passed on.
pub fn grpc_roles(grpc: &str) -> Roles {
let url = if grpc.starts_with("http") {
grpc.to_string()
} else {
format!("http://{grpc}")
};
Arc::new(move |auth: Option<String>| {
let url = url.clone();
Box::pin(async move {
use crate::certify::proto::{admin_service_client::AdminServiceClient, WhoAmIRequest};
let ep = tonic::transport::Endpoint::from_shared(url.clone())
.map_err(|e| format!("{url}: {e}"))?
.connect_timeout(std::time::Duration::from_secs(5));
let mut c =
AdminServiceClient::new(ep.connect().await.map_err(|e| format!("{url}: {e}"))?);
let mut r = tonic::Request::new(WhoAmIRequest::default());
if let Some(a) = auth.and_then(|a| a.parse().ok()) {
r.metadata_mut().insert("authorization", a);
}
let w = c
.who_am_i(r)
.await
.map_err(|s| format!("WhoAmI: {}", s.message()))?
.into_inner();
Ok(if w.authenticated {
w.global_role
} else {
String::new()
})
})
})
}
#[derive(Clone)]
struct Ctx {
root: PathBuf,
roles: Roles,
}
/// **The routes, on `root`** (the front's data root), each an admin's ([`Roles`]).
pub fn router(root: PathBuf, roles: Roles) -> Router {
Router::new()
.route(CERTS_PATH, get(certs))
.route(TRY_PATH, post(try_now))
.with_state(Ctx { root, roles })
}
/// Only a global `admin`, as the primary says.
async fn admin(c: &Ctx, h: &HeaderMap) -> Result<(), Response> {
let auth = h
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
match (c.roles)(auth).await {
Ok(role) if role == "admin" => Ok(()),
Ok(role) if role.is_empty() => Err((
StatusCode::UNAUTHORIZED,
"REFUSED [front-certs-signed-out] sign in as an admin\n",
)
.into_response()),
Ok(role) => Err((
StatusCode::FORBIDDEN,
format!(
"REFUSED [front-certs-admin] the certificates are an admin's (you are {role})\n"
),
)
.into_response()),
Err(e) => Err((
StatusCode::BAD_GATEWAY,
format!("REFUSED [front-certs-whoami] the primary did not say who you are: {e}\n"),
)
.into_response()),
}
}
fn json(v: serde_json::Value) -> Response {
(
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
v.to_string(),
)
.into_response()
}
async fn certs(State(c): State<Ctx>, h: HeaderMap) -> Response {
if let Err(r) = admin(&c, &h).await {
return r;
}
json(estate_front::web::status::report_json(
&c.root,
std::time::SystemTime::now(),
))
}
async fn try_now(State(c): State<Ctx>, h: HeaderMap, body: String) -> Response {
if let Err(r) = admin(&c, &h).await {
return r;
}
let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
let host = v["host"].as_str().unwrap_or("").trim().to_ascii_lowercase();
if host.is_empty()
|| !host
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '.')
|| host.starts_with('.')
|| host.contains("..")
{
return (
StatusCode::BAD_REQUEST,
"REFUSED [tls-try-host] {\"host\": \"<a served name>\"}\n",
)
.into_response();
}
match estate_front::web::status::try_now(&c.root, &host, std::time::SystemTime::now()) {
Ok(words) => {
tracing::warn!("console: an admin asked {host}'s certificate to be tried now");
json(serde_json::json!({ "words": words }))
}
Err(e) => (StatusCode::CONFLICT, format!("{e}\n")).into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tower::ServiceExt as _;
fn as_role(role: &'static str) -> Roles {
Arc::new(move |auth: Option<String>| {
Box::pin(async move {
Ok(if auth.is_some() {
role.to_string()
} else {
String::new()
})
})
})
}
async fn call(r: Router, method: &str, path: &str, body: &str) -> (StatusCode, String) {
let req = axum::http::Request::builder()
.method(method)
.uri(path)
.header("authorization", "Bearer t")
.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 certificates on holger-ui's admin page** (owner 2026-10-25): an admin reads
/// edda estate-front's report; anyone else is refused; "try now" follows estate-front's rule.
#[tokio::test]
async fn an_admin_reads_the_certificates_and_try_now_follows_the_rule() {
let dir = tempfile::tempdir().unwrap();
let now = std::time::SystemTime::now();
estate_front::web::acme::failed_as(
dir.path(),
"v.example",
now,
estate_front::web::acme::FailKind::Validation,
);
estate_front::web::acme::failed_as(
dir.path(),
"a.example",
now,
estate_front::web::acme::FailKind::Acme,
);
let (st, _) = call(
router(dir.path().into(), as_role("reader")),
"GET",
CERTS_PATH,
"",
)
.await;
assert_eq!(
st,
StatusCode::FORBIDDEN,
"a reader is not shown the certificates"
);
let (st, _) = call(
router(dir.path().into(), as_role("admin")),
"POST",
TRY_PATH,
r#"{"host":"a.example"}"#,
)
.await;
assert_eq!(st, StatusCode::OK);
let (st, text) = call(
router(dir.path().into(), as_role("admin")),
"GET",
CERTS_PATH,
"",
)
.await;
assert_eq!(st, StatusCode::OK, "{text}");
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(v["certificates"].as_array().unwrap().len(), 2, "{v}");
assert!(
v["certificates"][1]["lines"][3]
.as_str()
.unwrap()
.contains("refused"),
"{v}"
);
let (st, text) = call(
router(dir.path().into(), as_role("admin")),
"POST",
TRY_PATH,
r#"{"host":"v.example"}"#,
)
.await;
assert_eq!(st, StatusCode::CONFLICT, "{text}");
assert!(text.contains("tls-try-now-validation"), "{text}");
let (st, _) = call(
router(dir.path().into(), as_role("writer")),
"POST",
TRY_PATH,
r#"{"host":"a.example"}"#,
)
.await;
assert_eq!(st, StatusCode::FORBIDDEN);
let (st, _) = call(
router(dir.path().into(), as_role("admin")),
"POST",
TRY_PATH,
r#"{"host":"../x"}"#,
)
.await;
assert_eq!(st, StatusCode::BAD_REQUEST);
}
}