Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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);
    }
}