Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bin/hctl/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions bin/hyperion-web/src/filters.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> {
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(), "—");
}
}
2 changes: 2 additions & 0 deletions bin/hyperion-web/src/handlers/certs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions bin/hyperion-web/src/handlers/hostings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -6551,6 +6554,49 @@ pub struct CertIssueForm {
pub require_dns_match: Option<String>,
}

#[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<SharedState>,
ctx: AuthCtx,
Form(form): Form<CertDeleteForm>,
) -> Result<Response, AppError> {
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<String> = 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<SharedState>,
ctx: AuthCtx,
Expand Down
5 changes: 5 additions & 0 deletions bin/hyperion-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion bin/hyperion-web/templates/certs.html
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ <h3>No certificates yet</h3>
<code>{{ r.node_id }}</code>
{% endif %}
</td>
<td class="right" title="{{ r.not_after }}">{{ r.not_after }}</td>
<td class="right" title="unix {{ r.not_after }}">{{ r.not_after|date }}</td>
<td class="right">
{% if r.days_left < 0 %}
<strong>{{ r.days_left.abs() }} days ago</strong>
Expand Down
42 changes: 32 additions & 10 deletions bin/hyperion-web/templates/hostings_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -999,10 +999,42 @@ <h2>
<dt>SANs</dt><dd>{{ cert.sans.join(", ") }}</dd>
{% endif %}
<dt>Not after</dt><dd class="muted numeric" title="{{ cert.not_after }}">{{ crate::handlers::stats::fmt_future(cert.not_after) }}</dd>
{% if !cert.fingerprint_sha256.is_empty() %}
<dt>Fingerprint</dt><dd><code style="font-size:0.74rem">{{ cert.fingerprint_sha256 }}</code></dd>
{% endif %}
</dl>

<div class="card-section">
{%- 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. #}
<h2 style="font-size:0.95rem;margin:0 0 0.6rem">Replace this certificate</h2>
<p class="muted" style="margin:0 0 1rem;font-size:0.88rem">
<code>{{ detail.domain }}</code> 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.
</p>
<p class="muted" style="margin:0 0 1rem;font-size:0.88rem">
Deleting it drops <code>{{ detail.domain }}</code> back to a self-signed
certificate. The site keeps serving, but browsers show a security warning
until a new certificate is issued.
</p>
<form method="post" action="/hostings/cert/delete"
data-confirm-title="Delete this certificate"
data-confirm-body="{{ detail.domain }} falls back to a self-signed certificate and visitors will see a browser security warning until you issue a new one. Continue?"
data-confirm-confirm-label="Delete certificate"
data-confirm-variant="danger">
<input type="hidden" name="_csrf" value="{{ csrf_cert_delete }}">
<input type="hidden" name="selector" value="{{ detail.id.as_str() }}">
<button class="btn danger" type="submit">Delete certificate</button>
</form>
</div>
<div class="card-section" hidden>
{%- endif %}
<h2 style="font-size:0.95rem;margin:0 0 0.6rem">Issue a trusted Let's Encrypt certificate</h2>
<p class="muted" style="margin:0 0 1rem;font-size:0.88rem">
First confirm that DNS for <code>{{ detail.domain }}</code> resolves to this server. Issuance refuses to start otherwise.
Expand Down Expand Up @@ -1030,11 +1062,6 @@ <h2 style="font-size:0.95rem;margin:0 0 0.6rem">Issue a trusted Let's Encrypt ce
data-confirm-variant="primary">
<input type="hidden" name="_csrf" value="{{ csrf_cert_issue }}">
<input type="hidden" name="selector" value="{{ detail.id.as_str() }}">
<label class="field-checkbox">
<input type="checkbox" name="staging" value="on" checked>
Use Let's Encrypt <strong>staging</strong> first (recommended)
<span class="muted" style="margin-left:0.4rem;font-size:0.82rem">— untrusted CA, but no rate limits</span>
</label>
<label class="field-checkbox">
<input type="checkbox" name="require_dns_match" value="off">
<strong>Skip</strong> the DNS pre-check
Expand Down Expand Up @@ -1070,11 +1097,6 @@ <h2 style="font-size:0.95rem;margin:0 0 0.35rem">Wildcard certificate (DNS-01)</
<form method="post" action="/hostings/cert/dns01/begin">
<input type="hidden" name="_csrf" value="{{ csrf_token }}">
<input type="hidden" name="selector" value="{{ detail.id.as_str() }}">
<label class="field-checkbox">
<input type="checkbox" name="staging" value="on">
Use Let's Encrypt <strong>staging</strong> first
<span class="muted" style="margin-left:0.4rem;font-size:0.82rem">— untrusted, but no rate limits</span>
</label>
<button class="btn" type="submit" style="margin-top:0.4rem">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20z"/></svg>
Start wildcard issuance
Expand Down
54 changes: 35 additions & 19 deletions crates/hyperion-adapters/src/cert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,37 +59,53 @@ pub struct ValidatedCert {
pub fingerprint: String,
}

/// Validate `cert_pem` + `key_pem` (+ optional `ca_bundle_pem`) for a
/// hosting whose certificate must cover every name in `required_names`
/// (primary domain followed by aliases).
/// Read what a certificate already on disk actually says, without needing
/// its private key.
///
/// On success the leaf parses, the key matches it, and every required
/// name is covered. The returned `fullchain_pem` is the leaf chain with
/// the CA bundle appended.
/// Read `notAfter` + issuer label out of a certificate already on disk,
/// without needing its private key.
/// Two callers, same need — describe a certificate nobody handed us:
///
/// This exists for ADOPTION: a certificate issued before Hyperion recorded
/// it. The panel's own cert is the case that matters — it was written
/// straight to disk and never entered the `certificates` table, so the
/// renewal sweep, which reads only that table, could not see it, and the one
/// certificate whose expiry locks the operator out of the UI they would use
/// to fix it was the only one nothing was watching.
/// * ADOPTION. The panel's own cert was written straight to disk and
/// never entered the `certificates` table, so the renewal sweep — which
/// reads only that table — could not see it, and the one certificate
/// whose expiry locks the operator out of the UI they would use to fix
/// it was the only one nothing was watching.
/// * DISPLAY. The hosting detail page showed an empty Fingerprint because
/// the database has no such column. Reading it from the file is also
/// more honest than a stored copy: it reports what is being SERVED, not
/// what we last remembered writing.
///
/// Adoption needs exactly these two fields and has no business demanding the
/// private key, so this deliberately does not reuse [`validate_upload`],
/// which cross-checks a key it does not have.
pub fn inspect_pem(cert_pem: &str) -> Result<(i64, String), CertError> {
/// Deliberately does not reuse [`validate_upload`], which cross-checks a
/// private key that neither caller has.
pub fn inspect_pem(cert_pem: &str) -> Result<InspectedCert, CertError> {
let chain = parse_chain(cert_pem)?;
let leaf_der = chain
.first()
.ok_or_else(|| CertError::BadCertificate("empty certificate chain".into()))?
.as_ref();
let (_, leaf) = X509Certificate::from_der(leaf_der)
.map_err(|e| CertError::BadCertificate(e.to_string()))?;
Ok((leaf.validity().not_after.timestamp(), issuer_label(&leaf)))
Ok(InspectedCert {
not_after: leaf.validity().not_after.timestamp(),
issuer: issuer_label(&leaf),
fingerprint: crate::acme::fingerprint_sha256_der(leaf_der),
})
}

/// What [`inspect_pem`] can tell you about a certificate already on disk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InspectedCert {
pub not_after: i64,
pub issuer: String,
pub fingerprint: String,
}

/// Validate `cert_pem` + `key_pem` (+ optional `ca_bundle_pem`) for a
/// hosting whose certificate must cover every name in `required_names`
/// (primary domain followed by aliases).
///
/// On success the leaf parses, the key matches it, and every required
/// name is covered. The returned `fullchain_pem` is the leaf chain with
/// the CA bundle appended.
pub fn validate_upload(
cert_pem: &str,
key_pem: &str,
Expand Down
4 changes: 4 additions & 0 deletions crates/hyperion-core/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,10 @@ impl<A: AdapterPort + 'static> AgentApi for AgentImpl<A> {
self.svc.issue_real_cert(sel, req).await
}

async fn cert_delete(&self, sel: HostingSelector) -> Result<(), RpcError> {
self.svc.cert_delete(sel).await
}

async fn cert_dns01_begin(
&self,
sel: HostingSelector,
Expand Down
Loading