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/renew.rs

//! **The front renews every paired link before it ends** (lane E2, 2026-10-19; Gunnar's
//! lifecycle pattern). The schedule and the renewal steps are SHARED, Skidbladnir
//! `nordisk_pairing::frontside::renew` (feature `front-renew`, off by default; P4c, A28 M11);
//! what is Holger's: the two jera jobs, where the kept material lives, and the twin's leaf.
//!
//! Until this module nothing in a Holger estate renewed a certificate: the boxes'
//! own (180 days, signed by the installer CA at the join), the installer CA's own
//! (180 days), and the twin's replication leaf (365 days) ran to their ends, and the
//! estate went dark. Now the front looks, as two jera jobs on its own ledger
//! ([`JOBS_REL`]), every [`LOOK_EVERY`]:
//!
//! | job | link | what, at two thirds of a life |
//! |---|---|---|
//! | [`KIND_TWIN`] | twin → primary (replication) | the twin is re-certified by its primary: the same key (the twin's request is kept), a new leaf under the pinned issuer — `nordisk-twin` takes it as a renewal |
//! | [`KIND_BOXES`] (`metered`) | front → main, twin (the installer's door) | the installer CA renewed from the kept material (the same key and name, so every pin holds) and stored back into the primary's sealed slot; then each box's own certificate re-signed for ITS key, a fresh bootstrap handed with it |
//!
//! **No downtime.** A renewal never revokes: the old certificate is valid to its
//! end and is served until the new one is written over it (atomically, on the box),
//! and a renewed CA keeps its key, so both CA certificates anchor the same chains.
//!
//! **WireGuard only.** The boxes are renewed over the installer's own WireGuard link
//! (`nordisk_pairing::kept::paths_for`: a userspace WireGuard session to each box,
//! the front's own door direct); the twin is re-certified through
//! [`crate::certify::HolgerBoxes`], which refuses any address off the overlay by
//! name (`holger-twin-17`) before it dials.
//!
//! **Retried, then alerted.** Each link keeps a schedule ([`Link`], kept in
//! [`STATE_REL`]): a look that fails is retried after [`retry_after`] (10 min,
//! doubling, at most [`RENEW_EVERY`]); from the [`ALERT_AFTER`]th failure in a row
//! every failed look says `ALERT [front-renew]` at ERROR. The boxes' own health
//! words (holger-server `cert_health`: `expiring`, `expired`, `cert_alert`) carry
//! the same alarm to the ONE box's guardian, whoever failed to renew.

use std::path::Path;
use std::time::Duration;

pub use nordisk_pairing::frontside::renew::{
    look, renew_boxes, renew_ca_due, retry_after, Link, Looked, State, ALERT_AFTER, BOX_VALID,
    RENEW_EVERY, RETRY_FIRST, STATE_REL,
};

/// How often the ticker submits the jobs (a job not due does nothing).
pub const LOOK_EVERY: Duration = Duration::from_secs(120);
/// The renewal's own job ledger, under the data root.
pub const JOBS_REL: &str = "jobs-renew";
/// The twin's replication certificate.
pub const KIND_TWIN: &str = "front.renew.twin";
/// The boxes' own certificates and the installer CA (`metered`: the kept material).
pub const KIND_BOXES: &str = "front.renew.boxes";
/// The name of the twin's link in the schedule.
pub const LINK_TWIN: &str = "twin";
/// The name of the boxes' link in the schedule.
pub const LINK_BOXES: &str = "boxes";
/// The share of a life after which a certificate is renewed (nordisk-pairing's rule).
const RENEW_AT: (i64, i64) = nordisk_pairing::mtls::RENEW_AT;

// ─── the twin's replication certificate ─────────────────────────────────

/// Pure: **is the twin's certificate due at `now`?** Two thirds of the life the record
/// keeps; a record without one (written before lane E2) is due, so the renewal learns it.
pub fn twin_due(rec: &crate::certify::Record, now: i64) -> bool {
    if rec.not_after <= rec.certified_at {
        return true;
    }
    now >= rec.certified_at + (rec.not_after - rec.certified_at) * RENEW_AT.0 / RENEW_AT.1
}

/// **Re-certify the twin when due** at `now`: the same three steps as its first
/// certification (`TwinCsr` answers the request it kept, so the key stays; the primary's
/// issuer signs a new leaf; the twin installs it as a renewal under the issuer it pins).
/// The old leaf is followed with until the new one is written. A pair the front never
/// certified is the role loop's first certification, not a renewal.
pub fn renew_twin(
    root: &Path,
    boxes: &dyn nordisk_twin::certify::Boxes,
    t: &nordisk_twin::certify::Target,
    now: i64,
) -> Result<String, String> {
    match crate::certify::Record::load(root) {
        Some(rec) if rec.covers(t) => {
            if !twin_due(&rec, now) {
                return Ok(format!(
                    "the twin's certificate is not due ({} day(s) left)",
                    (rec.not_after - now).div_euclid(86_400)
                ));
            }
            let p = crate::certify::run_at(root, boxes, t, std::iter::empty(), now)
                .map_err(|e| format!("the twin at {} was not re-certified: {e}", t.reader_ctl))?;
            Ok(format!(
                "the twin at {} was re-certified: {} day(s) of certificate",
                t.reader_ctl, p.days_left
            ))
        }
        _ => Ok("the twin is not certified yet: the role loop certifies it first".into()),
    }
}

// ─── the jobs ───────────────────────────────────────────────────────────

fn spec(kind: &str, generation: i64) -> jera::jobs::ChainSpec {
    jera::jobs::ChainSpec::new(
        kind,
        kind,
        kind,
        generation,
        "holger-front",
        serde_json::json!({}),
    )
    .with_priority(jera::jobs::Priority::Normal)
}

fn twin_job(
    vm: &crate::FrontVm,
    handle: &tokio::runtime::Handle,
    now: i64,
) -> Result<Looked, String> {
    let reply = vm.latest().ok_or("not fed yet")?;
    let Some(t) = crate::certify::target_of(&reply) else {
        return Ok(Looked::NotDue);
    };
    let Some(bearers) = crate::console_key::bearers(&reply)? else {
        return Ok(Looked::NotDue);
    };
    let boxes = crate::certify::HolgerBoxes {
        handle: handle.clone(),
        bearers: bearers.into(),
        timeout: Duration::from_secs(30),
        wg_only: crate::wg_only::wg_only(),
    };
    look(vm.root(), LINK_TWIN, now, || {
        renew_twin(vm.root(), &boxes, &t, now)
    })
}

/// The boxes and the CA from the kept material in the primary's sealed slot: the CA
/// first, stored back before anything is signed with it (a renewed CA keeps its key, so
/// the boxes' certificates chain the same either way), then every box.
#[cfg(feature = "metered")]
fn boxes_job(
    vm: &crate::FrontVm,
    handle: &tokio::runtime::Handle,
    now: i64,
) -> Result<Looked, String> {
    let reply = vm.latest().ok_or("not fed yet")?;
    let Some(slot) = crate::sealed::slot_of(&reply, handle) else {
        return Ok(Looked::NotDue);
    };
    look(vm.root(), LINK_BOXES, now, || {
        let bytes = slot
            .get(crate::sealed::MATERIAL_OBJECT)?
            .filter(|b| !b.is_empty())
            .ok_or_else(|| format!("no installer material is kept in {} yet", slot.describe()))?;
        let mut kept = nordisk_pairing::kept::Kept::parse(&bytes)?;
        let mut said = Vec::new();
        if let Some(ca) = renew_ca_due(&kept.ca()?, nordisk_pairing::kept::MATERIAL_VALID, now)? {
            kept.files
                .insert(nordisk_pairing::installer::CA_CERT.to_string(), ca.cert_pem);
            slot.put(crate::sealed::MATERIAL_OBJECT, &kept.render()?)
                .map_err(|e| {
                    format!(
                        "the renewed installer CA could not be stored in {}: {e}",
                        slot.describe()
                    )
                })?;
            said.push(
                "the installer CA was renewed (the same key and name) and stored back".to_string(),
            );
        }
        let m = kept.material()?;
        let link = m
            .link
            .clone()
            .ok_or("the kept material holds no link keys")?;
        let paths = nordisk_pairing::kept::paths_for(&kept.plan, &link)?
            .into_iter()
            .map(|(v, p)| (v.name, p))
            .collect();
        let valid = kept
            .plan
            .box_cert_valid_s
            .map(Duration::from_secs)
            .unwrap_or(BOX_VALID);
        said.push(renew_boxes(&m, paths, valid, now)?);
        Ok(said.join("; "))
    })
}

fn run(
    kind: &str,
    vm: &crate::FrontVm,
    handle: &tokio::runtime::Handle,
) -> anyhow::Result<serde_json::Value> {
    let now = nordisk_pairing::mtls::now_unix();
    let looked = match kind {
        KIND_TWIN => twin_job(vm, handle, now),
        #[cfg(feature = "metered")]
        KIND_BOXES => boxes_job(vm, handle, now),
        other => Err(format!("no renewal job {other:?}")),
    }
    .map_err(|e| anyhow::anyhow!(e))?;
    Ok(match looked {
        Looked::NotDue => serde_json::json!({ "due": false }),
        Looked::Done(words) => {
            tracing::info!(kind, "renew: {words}");
            serde_json::json!({ "words": words })
        }
    })
}

/// **Start the renewal** (inside a runtime): the front's own renewal ledger, a ticker that
/// submits each job every [`LOOK_EVERY`] under ONE idempotency key per kind, the work on
/// the queue's thread.
pub fn start(vm: std::sync::Arc<crate::FrontVm>, root: std::path::PathBuf) {
    let handle = tokio::runtime::Handle::current();
    let sink = match jera::jobs::JobStore::open(&root.join(JOBS_REL)) {
        Ok(s) => s.sink(),
        Err(e) => {
            return tracing::error!(error = %e, "ALERT [front-renew] the renewal's job ledger cannot open: NO paired certificate is renewed, and the estate's links end with them")
        }
    };
    let q = jera::jobs::ChainQueue::new(sink);
    let (v, h) = (std::sync::Arc::clone(&vm), handle.clone());
    // A32-14, stays a task: the renew lane's jera worker.
    handle.spawn(std::sync::Arc::clone(&q).run(move |s| run(&s.kind, &v, &h)));
    // A32-14, stays a task: the renew lane's ticker (it submits the jera looks).
    handle.spawn(async move {
        let mut generation = 0i64;
        loop {
            generation += 1;
            let _ = q.submit(spec(KIND_TWIN, generation));
            #[cfg(feature = "metered")]
            let _ = q.submit(spec(KIND_BOXES, generation));
            tokio::time::sleep(LOOK_EVERY).await;
        }
    });
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_record_from_before_the_renewal_is_due_and_a_fresh_one_at_two_thirds() {
        let old = crate::certify::Record {
            days_left: 300,
            ..Default::default()
        };
        assert!(twin_due(&old, 1));
        let rec = crate::certify::Record {
            certified_at: 1_000,
            not_after: 1_000 + 365 * 86_400,
            ..Default::default()
        };
        assert!(!twin_due(&rec, 1_000 + 243 * 86_400));
        assert!(
            twin_due(&rec, 1_000 + 244 * 86_400),
            "day 244 of 365 is past two thirds"
        );
    }
}