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