diff --git a/bin/hctl/src/main.rs b/bin/hctl/src/main.rs index 781ebc9..cfe7652 100644 --- a/bin/hctl/src/main.rs +++ b/bin/hctl/src/main.rs @@ -941,6 +941,7 @@ fn print_pretty(resp: &Response) { println!("matches: {}", if c.matches { "yes ✓" } else { "no ✗" }); println!("note: {}", c.note); } + Response::CertDelete => println!("✓ certificate deleted — the site is on a self-signed bootstrap cert until you issue a new one"), Response::CertIssueAcme(c) | Response::CertDns01Finish(c) | Response::CertDns01FinishDomain(c) diff --git a/bin/hyperion-web/src/filters.rs b/bin/hyperion-web/src/filters.rs new file mode 100644 index 0000000..9e1838a --- /dev/null +++ b/bin/hyperion-web/src/filters.rs @@ -0,0 +1,50 @@ +//! Askama template filters. +//! +//! Askama resolves `{{ x|foo }}` against a module named `filters` that is in +//! scope where the template struct is defined, so `use crate::filters;` next +//! to a `#[derive(Template)]` makes everything here available to it. +//! +//! These exist because raw unix timestamps kept reaching the screen. A cell +//! reading `1794055205` is not a date to anyone — it is the kind of thing +//! that is obvious to whoever wrote the query and useless to whoever reads +//! the page. + +/// A unix timestamp as `YYYY-MM-DD` (UTC). +/// +/// UTC rather than local time on purpose: the agent, the database and +/// Let's Encrypt all speak UTC, so rendering local time here would make the +/// panel disagree with `openssl x509 -dates` and with its own logs for +/// anyone east or west of Greenwich — for a value whose precision only +/// matters to the day. +pub fn date(ts: &i64) -> askama::Result { + Ok(chrono::DateTime::from_timestamp(*ts, 0) + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "—".to_string())) +} + +/// A unix timestamp as `YYYY-MM-DD HH:MM` (UTC), for values where the time +/// of day carries information (an audit entry, a last-checked stamp). +pub fn datetime(ts: &i64) -> askama::Result { + Ok(chrono::DateTime::from_timestamp(*ts, 0) + .map(|d| d.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "—".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats_a_known_timestamp() { + // 2026-11-07T12:40:05Z — the shape the certs table renders. + assert_eq!(date(&1_794_055_205).unwrap(), "2026-11-07"); + assert_eq!(datetime(&1_794_055_205).unwrap(), "2026-11-07 12:40"); + } + + /// Out-of-range input renders an em dash rather than panicking or + /// printing something that looks like a real date. + #[test] + fn out_of_range_is_a_dash() { + assert_eq!(date(&i64::MAX).unwrap(), "—"); + } +} diff --git a/bin/hyperion-web/src/handlers/certs.rs b/bin/hyperion-web/src/handlers/certs.rs index 26923ad..570937e 100644 --- a/bin/hyperion-web/src/handlers/certs.rs +++ b/bin/hyperion-web/src/handlers/certs.rs @@ -8,6 +8,8 @@ use crate::auth::AuthCtx; use crate::error::AppError; +#[allow(unused_imports)] // askama resolves {{ x|date }} through this +use crate::filters; use crate::state::SharedState; use askama::Template; use axum::extract::State; diff --git a/bin/hyperion-web/src/handlers/hostings.rs b/bin/hyperion-web/src/handlers/hostings.rs index 946806c..dc5ff94 100644 --- a/bin/hyperion-web/src/handlers/hostings.rs +++ b/bin/hyperion-web/src/handlers/hostings.rs @@ -149,6 +149,7 @@ struct DetailTpl<'a> { csrf_expiry_clear: String, csrf_dns_check: String, csrf_cert_issue: String, + csrf_cert_delete: String, csrf_restore: String, csrf_restore_as_new: String, csrf_logs: String, @@ -1248,6 +1249,7 @@ pub async fn post_create( csrf_expiry_clear: csrf_token_for(&state, &ctx, "/hostings/expiry/clear"), csrf_dns_check: csrf_token_for(&state, &ctx, "/hostings/dns-check"), csrf_cert_issue: csrf_token_for(&state, &ctx, "/hostings/cert/issue"), + csrf_cert_delete: csrf_token_for(&state, &ctx, "/hostings/cert/delete"), csrf_restore: csrf_token_for(&state, &ctx, "/hostings/restore"), csrf_restore_as_new: csrf_token_for(&state, &ctx, "/hostings/restore-as-new"), csrf_logs: csrf_token_for(&state, &ctx, "/hostings/logs"), @@ -2053,6 +2055,7 @@ pub async fn get_detail( csrf_expiry_clear: csrf_token_for(&state, &ctx, "/hostings/expiry/clear"), csrf_dns_check: csrf_token_for(&state, &ctx, "/hostings/dns-check"), csrf_cert_issue: csrf_token_for(&state, &ctx, "/hostings/cert/issue"), + csrf_cert_delete: csrf_token_for(&state, &ctx, "/hostings/cert/delete"), csrf_restore: csrf_token_for(&state, &ctx, "/hostings/restore"), csrf_restore_as_new: csrf_token_for(&state, &ctx, "/hostings/restore-as-new"), csrf_logs: csrf_token_for(&state, &ctx, "/hostings/logs"), @@ -6551,6 +6554,49 @@ pub struct CertIssueForm { pub require_dns_match: Option, } +#[derive(serde::Deserialize)] +pub struct CertDeleteForm { + pub selector: String, +} + +/// POST /hostings/cert/delete — drop the certificate so a new one can be +/// issued. +/// +/// Synchronous, unlike issuance: this is a file removal plus a vhost +/// re-render, with no third party to wait on. Runs on the OWNING node — +/// the certificate and the vhost both live there. +pub async fn post_cert_delete( + State(state): State, + ctx: AuthCtx, + Form(form): Form, +) -> Result { + let sel = + match require_manage_for_selector(&state, &ctx, &form.selector, Capability::CertManage) + .await + { + Ok(s) => s, + Err(r) => return Ok(r), + }; + let sel_url = urlencoding(&form.selector); + let node: Option = find_hosting_anywhere(&state, sel.clone()) + .await + .ok() + .and_then(|(_d, n)| n); + let resp = + crate::dispatcher::dispatch_to_node(&state, node.as_deref(), Request::CertDelete { sel }) + .await?; + match resp { + RpcResponse::CertDelete => { + Ok(Redirect::to(&format!("/hostings/{sel_url}?cert=deleted")).into_response()) + } + RpcResponse::Error(e) => { + let msg = urlencoding(&e.to_string()); + Ok(Redirect::to(&format!("/hostings/{sel_url}?cert_error={msg}")).into_response()) + } + _ => Err(AppError::Internal("unexpected response".into())), + } +} + pub async fn post_cert_issue( State(state): State, ctx: AuthCtx, diff --git a/bin/hyperion-web/src/lib.rs b/bin/hyperion-web/src/lib.rs index 7b31d31..710a35b 100644 --- a/bin/hyperion-web/src/lib.rs +++ b/bin/hyperion-web/src/lib.rs @@ -19,6 +19,7 @@ pub mod auth; pub mod config; pub mod dispatcher; pub mod error; +pub mod filters; pub mod handlers; pub mod ratelimit; pub mod state; @@ -144,6 +145,10 @@ pub fn build_router(state: SharedState) -> Router { "/hostings/cert/issue", post(handlers::hostings::post_cert_issue), ) + .route( + "/hostings/cert/delete", + post(handlers::hostings::post_cert_delete), + ) .route( "/hostings/cert/dns01/begin", post(handlers::hostings::post_cert_dns01_begin), diff --git a/bin/hyperion-web/templates/certs.html b/bin/hyperion-web/templates/certs.html index f5e8cec..6680c68 100644 --- a/bin/hyperion-web/templates/certs.html +++ b/bin/hyperion-web/templates/certs.html @@ -135,7 +135,7 @@

No certificates yet

{{ r.node_id }} {% endif %} - {{ r.not_after }} + {{ r.not_after|date }} {% if r.days_left < 0 %} {{ r.days_left.abs() }} days ago diff --git a/bin/hyperion-web/templates/hostings_detail.html b/bin/hyperion-web/templates/hostings_detail.html index 20da8b1..9cdbc2f 100644 --- a/bin/hyperion-web/templates/hostings_detail.html +++ b/bin/hyperion-web/templates/hostings_detail.html @@ -999,10 +999,42 @@

SANs
{{ cert.sans.join(", ") }}
{% endif %}
Not after
{{ crate::handlers::stats::fmt_future(cert.not_after) }}
+ {% if !cert.fingerprint_sha256.is_empty() %}
Fingerprint
{{ cert.fingerprint_sha256 }}
+ {% endif %}
+ {%- if cert.issuer != "self-signed" %} + {# A working certificate is in place. Re-issuing over it is nearly always + a mistake — it spends Let's Encrypt's per-hostname budget on something + that already works, and a failed attempt leaves the site worse off than + before. So make replacing it a deliberate two-step: delete, then issue. #} +

Replace this certificate

+

+ {{ detail.domain }} already has a trusted certificate, and it + renews itself — there is normally nothing to do here. Issuing a new one is + only useful after changing the domain's aliases, and Let's Encrypt limits + how often that can be repeated, so the current certificate has to be + removed first. +

+

+ Deleting it drops {{ detail.domain }} back to a self-signed + certificate. The site keeps serving, but browsers show a security warning + until a new certificate is issued. +

+
+ + + +
+
+