crates/estate-front/src/web/status.rs
//! **What the front says about each certificate** (owner 2026-10-25: certificates must fix
//! themselves): per certificate (named by its first host) when it is valid until, when ACME
//! is asked next, and the last reason it is not served or not renewed. It is read from the
//! data disk ([`report`]), so a console in another process on the same box (the ONE box's)
//! reads it as well as one in the front's own; the consoles and CLIs only SHOW it
//! ([`HostStatus::to_json`], [`render`]).
//!
//! **"Try now"** ([`try_now`]): an admin may skip the backoff, but only when the last
//! failure was not a failed validation, which is what Let's Encrypt counts (five per name per
//! hour). The front's loop sees the ask within a feed tick.
//!
//! **The alert**: a host with no valid certificate for more than [`ALERT_AFTER`] is said as
//! `ALERT tls.no-certificate: …` with its reason ([`alert_line`]), once per reason; the ONE
//! box mails every `ALERT <kind>` line to its operator. The status carries it while it holds.
//!
//! On disk: `tls/<host>/acme-status.json` (this module's record), beside the kept pair and
//! `acme-next` (the backoff, [`super::acme`]), and `tls/<host>/acme-try-now` (an ask).
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{acme, certs};
/// A host with no valid certificate for longer than this is an alert.
pub const ALERT_AFTER: Duration = Duration::from_secs(3600);
/// The kinds of last reason the record keeps (beside [`acme::FailKind::as_str`]).
pub const KIND_DNS: &str = "dns";
pub const KIND_UNFED: &str = "unfed";
/// ACME is switched off (the product's switch, or a front built without `acme`; AIRGAP B1).
pub const KIND_OFF: &str = "off";
fn record_rel(host: &str) -> String {
format!("{}/{host}/acme-status.json", certs::TLS_REL)
}
fn ask_rel(host: &str) -> String {
format!("{}/{host}/acme-try-now", certs::TLS_REL)
}
fn secs(t: SystemTime) -> u64 {
t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// **What the loop records** for a host, kept on the data disk.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Record {
/// Every name the certificate is for.
pub hosts: Vec<String>,
/// `dns`, `unfed`, `validation`, `acme` — or none (nothing wrong).
pub kind: Option<String>,
pub reason: Option<String>,
/// Since when nothing valid has been served (unix seconds).
pub no_certificate_since: Option<u64>,
/// The reason last alerted for, so one reason is alerted once.
pub alerted: Option<String>,
}
impl Record {
pub fn load(root: &Path, host: &str) -> Record {
let v: serde_json::Value = std::fs::read_to_string(root.join(record_rel(host))).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
let s = |k: &str| v[k].as_str().map(str::to_string);
Record {
hosts: v["hosts"].as_array().map(|a| a.iter().filter_map(|h| h.as_str().map(str::to_string)).collect()).unwrap_or_default(),
kind: s("kind"),
reason: s("reason"),
no_certificate_since: v["no_certificate_since"].as_u64(),
alerted: s("alerted"),
}
}
pub fn save(&self, root: &Path, host: &str) -> Result<(), String> {
let v = serde_json::json!({"hosts": self.hosts, "kind": self.kind, "reason": self.reason, "no_certificate_since": self.no_certificate_since, "alerted": self.alerted});
crate::write_secret(root, &record_rel(host), &format!("{v}\n")).map_err(|e| format!("{e:#}"))
}
/// Set the last reason (`None`: nothing wrong).
pub fn said(&mut self, kind: Option<&str>, reason: Option<String>) {
self.kind = kind.map(str::to_string);
self.reason = reason;
}
/// **The look's verdict on what is served**, at `now`: a valid certificate clears the
/// clock and the alert; none starts the clock. Answers the alert line to say, once per
/// reason, after [`ALERT_AFTER`].
pub fn served(&mut self, host: &str, valid: bool, now: SystemTime) -> Option<String> {
if valid {
self.no_certificate_since = None;
self.alerted = None;
return None;
}
let since = *self.no_certificate_since.get_or_insert(secs(now));
let reason = self.reason.clone().unwrap_or_else(|| "no reason recorded yet".to_string());
let line = alert_line(host, UNIX_EPOCH + Duration::from_secs(since), now, &reason)?;
if self.alerted.as_deref() == Some(reason.as_str()) {
return None;
}
self.alerted = Some(reason);
Some(line)
}
}
/// Pure: **the alert line**, once a host has had no valid certificate for [`ALERT_AFTER`].
pub fn alert_line(host: &str, since: SystemTime, now: SystemTime, reason: &str) -> Option<String> {
let for_ = now.duration_since(since).ok().filter(|d| *d > ALERT_AFTER)?;
let m = for_.as_secs() / 60;
Some(format!("ALERT tls.no-certificate: {host} has had no valid certificate for {} h {} min: {reason}", m / 60, m % 60))
}
/// The status of one certificate (named by its first host).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HostStatus {
pub host: String,
pub hosts: Vec<String>,
/// The served certificate's notAfter (unix seconds); none: nothing valid.
pub valid_until: Option<u64>,
/// When ACME may be asked next (unix seconds), inside a backoff; none: no wait.
pub next_attempt: Option<u64>,
/// Real ACME failures in a row.
pub failures: u32,
pub last_kind: Option<String>,
pub last_reason: Option<String>,
pub no_certificate_since: Option<u64>,
/// The alert line while it holds.
pub alert: Option<String>,
/// Whether an admin's "try now" is taken ([`try_now_allowed`]).
pub try_now: bool,
}
impl HostStatus {
/// The JSON the consoles and CLIs take, with [`HostStatus::lines`] at `now`.
pub fn to_json(&self, now: SystemTime) -> serde_json::Value {
serde_json::json!({
"lines": self.lines(now),
"host": self.host, "hosts": self.hosts, "valid_until": self.valid_until, "next_attempt": self.next_attempt,
"failures": self.failures, "last_kind": self.last_kind, "last_reason": self.last_reason,
"no_certificate_since": self.no_certificate_since, "alert": self.alert, "try_now": self.try_now,
})
}
}
/// Pure: **may an admin skip the wait?** Not while a wait for a failed validation runs.
pub fn try_now_allowed(wait: Option<&acme::Wait>, now: SystemTime) -> bool {
!matches!(wait, Some(w) if w.kind == Some(acme::FailKind::Validation) && now < w.at)
}
/// The status of `host`, read from the disk.
pub fn host_status(root: &Path, host: &str, now: SystemTime) -> HostStatus {
let r = Record::load(root, host);
let valid_until = match certs::kept(root, host, now) {
Some(Ok(c)) => Some(secs(c.not_after)),
_ => None,
};
let wait = acme::wait(root, host);
let next_attempt = wait.as_ref().filter(|w| w.at > now).map(|w| secs(w.at));
let alert = match (valid_until, r.no_certificate_since) {
(None, Some(since)) => alert_line(host, UNIX_EPOCH + Duration::from_secs(since), now, r.reason.as_deref().unwrap_or("no reason recorded yet")),
_ => None,
};
HostStatus {
host: host.to_string(),
hosts: if r.hosts.is_empty() { vec![host.to_string()] } else { r.hosts.clone() },
valid_until,
next_attempt,
failures: wait.as_ref().map(|w| w.failures).unwrap_or(0),
last_kind: r.kind.clone(),
last_reason: r.reason.clone(),
no_certificate_since: if valid_until.is_some() { None } else { r.no_certificate_since },
alert,
try_now: try_now_allowed(wait.as_ref(), now),
}
}
/// **Every certificate this front knows of**: each host directory under `tls/` with a kept
/// certificate, a record or a wait, by name.
pub fn report(root: &Path, now: SystemTime) -> Vec<HostStatus> {
let Ok(dirs) = std::fs::read_dir(root.join(certs::TLS_REL)) else { return Vec::new() };
let mut names: Vec<String> = dirs.filter_map(|e| e.ok()).filter(|e| e.path().is_dir()).filter_map(|e| e.file_name().into_string().ok()).collect();
names.sort();
names
.into_iter()
.filter(|h| {
let d = certs::host_dir(root, h);
["acme-status.json", "acme-next", "pair.pem", "cert.pem"].iter().any(|f| d.join(f).exists())
})
.map(|h| host_status(root, &h, now))
.collect()
}
/// [`report`] as the JSON the consoles and CLIs show: `{"certificates": [...]}`.
pub fn report_json(root: &Path, now: SystemTime) -> serde_json::Value {
serde_json::json!({"certificates": report(root, now).iter().map(|s| s.to_json(now)).collect::<Vec<_>>()})
}
/// **An admin's "try now"** for `host`: refused while a wait for a failed validation runs;
/// otherwise the wait is skipped (the count of failures kept, so a next failure still
/// doubles) and the loop is asked to look at once. Answers what to tell the admin.
pub fn try_now(root: &Path, host: &str, now: SystemTime) -> Result<String, String> {
let wait = acme::wait(root, host);
if !try_now_allowed(wait.as_ref(), now) {
let at = wait.as_ref().map(|w| secs(w.at)).unwrap_or(0);
return Err(format!(
"REFUSED [tls-try-now-validation] the last ACME attempt for {host} failed Let's Encrypt's validation, which it counts (five per name per hour); it is asked again at unix {at}"
));
}
if let Some(w) = wait.filter(|w| w.at > now) {
acme::wait_until(root, host, now, w.failures, w.kind);
}
crate::write_secret(root, &ask_rel(host), "try now\n").map_err(|e| format!("{e:#}"))?;
Ok(format!("tls: {host} is looked at now (DNS first, then ACME)"))
}
/// Taken by the loop: whether an admin asked "try now" for `host` (the ask is consumed).
pub fn take_ask(root: &Path, host: &str) -> bool {
std::fs::remove_file(root.join(ask_rel(host))).is_ok()
}
/// Pure: **the report as text**, for a CLI: one block per certificate ([`HostStatus::lines`]).
pub fn render(list: &[HostStatus], now: SystemTime) -> String {
if list.is_empty() {
return "no certificate is known to this front yet\n".to_string();
}
let mut out = String::new();
for s in list {
out.push_str(&format!("{} ({})\n", s.host, s.hosts.join(", ")));
for l in s.lines(now) {
out.push_str(&format!(" {l}\n"));
}
}
out
}
impl HostStatus {
/// Pure: **the words a console or a CLI shows** for this certificate, one per line, so
/// neither decides anything.
pub fn lines(&self, now: SystemTime) -> Vec<String> {
let when = |t: u64| {
let n = secs(now);
if t >= n {
format!("unix {t} (in {} min)", (t - n) / 60)
} else {
format!("unix {t} ({} min ago)", (n - t) / 60)
}
};
let mut out = vec![
format!("valid until: {}", self.valid_until.map(when).unwrap_or_else(|| "no valid certificate".into())),
format!("next attempt: {}", self.next_attempt.map(|t| format!("{} after {} failure(s)", when(t), self.failures)).unwrap_or_else(|| "no wait".into())),
format!("last reason: {}", self.last_reason.as_deref().unwrap_or("none")),
format!("try now: {}", if self.try_now { "allowed" } else { "refused (a failed validation's wait runs)" }),
];
out.extend(self.alert.clone());
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_record_survives_a_reboot_and_one_reason_is_alerted_once() {
let dir = tempfile::tempdir().unwrap();
let t = SystemTime::now();
let mut r = Record { hosts: vec!["a.test".into()], ..Record::default() };
r.said(Some(KIND_DNS), Some("dns: a".into()));
assert_eq!(r.served("a.test", false, t), None, "the clock starts");
assert!(r.served("a.test", false, t + ALERT_AFTER + Duration::from_secs(60)).is_some());
assert_eq!(r.served("a.test", false, t + ALERT_AFTER + Duration::from_secs(120)), None, "said once per reason");
r.said(Some("validation"), Some("ACME: b".into()));
assert!(r.served("a.test", false, t + ALERT_AFTER + Duration::from_secs(180)).unwrap().ends_with("ACME: b"), "a new reason is said again");
r.save(dir.path(), "a.test").unwrap();
assert_eq!(Record::load(dir.path(), "a.test"), r);
r.served("a.test", true, t);
assert_eq!((r.no_certificate_since, r.alerted), (None, None), "a valid certificate clears it");
}
#[test]
fn the_text_report_names_every_field() {
let now = UNIX_EPOCH + Duration::from_secs(10_000);
let s = HostStatus { host: "a.test".into(), hosts: vec!["a.test".into(), "www.a.test".into()], next_attempt: Some(10_600), failures: 2, last_reason: Some("ACME: x".into()), ..HostStatus::default() };
let t = render(&[s], now);
assert!(t.contains("a.test (a.test, www.a.test)") && t.contains("no valid certificate") && t.contains("in 10 min) after 2 failure(s)") && t.contains("ACME: x") && t.contains("refused"), "{t}");
assert!(render(&[], now).contains("no certificate"));
let j = HostStatus { host: "a.test".into(), try_now: true, ..HostStatus::default() }.to_json(now);
assert_eq!((j["host"].as_str(), j["try_now"].as_bool(), j["lines"][3].as_str()), (Some("a.test"), Some(true), Some("try now: allowed")));
}
}