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

nordisk/edda

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