use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;
use serde::{Deserialize, Serialize};
pub const RENEW_EVERY: Duration = Duration::from_secs(6 * 3600);
pub const RETRY_FIRST: Duration = Duration::from_secs(600);
pub const ALERT_AFTER: u32 = 3;
pub const STATE_REL: &str = "pairing/renewal.toml";
pub const BOX_VALID: Duration = Duration::from_secs(180 * 24 * 3600);
pub fn retry_after(failures: u32) -> Duration {
let n = failures.saturating_sub(1).min(16);
RETRY_FIRST.saturating_mul(1u32 << n).min(RENEW_EVERY)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Link {
#[serde(default)]
pub next: i64,
#[serde(default)]
pub last_ok: i64,
#[serde(default)]
pub failures: u32,
#[serde(default)]
pub last_error: String,
}
impl Link {
pub fn due(&self, now: i64) -> bool {
now >= self.next
}
pub fn ok(&mut self, now: i64) {
self.last_ok = now;
self.failures = 0;
self.last_error.clear();
self.next = now + RENEW_EVERY.as_secs() as i64;
}
pub fn failed(&mut self, now: i64, why: &str) {
self.failures = self.failures.saturating_add(1);
self.last_error = why.to_string();
self.next = now + retry_after(self.failures).as_secs() as i64;
}
pub fn alert(&self, name: &str) -> Option<String> {
(self.failures >= ALERT_AFTER).then(|| {
format!(
"ALERT [front-renew] the {name} link's renewal failed {} times in a row (next try in {} s): {}. Its certificate keeps serving until its end; the boxes' health words say `expiring` once the end is near",
self.failures,
retry_after(self.failures).as_secs(),
self.last_error
)
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct State {
#[serde(default)]
pub links: BTreeMap<String, Link>,
}
impl State {
pub fn load(root: &Path) -> State {
std::fs::read_to_string(root.join(STATE_REL)).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
}
pub fn save(&self, root: &Path) -> Result<(), String> {
let text = toml::to_string(self).map_err(|e| e.to_string())?;
crate::boxside::write_atomic(root, STATE_REL, text.as_bytes(), false).map_err(|e| format!("{}: {e}", root.join(STATE_REL).display()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Looked {
NotDue,
Done(String),
}
pub fn look(root: &Path, link: &str, now: i64, work: impl FnOnce() -> Result<String, String>) -> Result<Looked, String> {
let mut state = State::load(root);
let entry = state.links.entry(link.to_string()).or_default();
if !entry.due(now) {
return Ok(Looked::NotDue);
}
let r = work();
match &r {
Ok(_) => entry.ok(now),
Err(e) => entry.failed(now, e),
}
let (alert, failures) = (entry.alert(link), entry.failures);
state.save(root)?;
match r {
Ok(words) => Ok(Looked::Done(words)),
Err(e) => {
match alert {
Some(a) => tracing::error!("{a}"),
None => tracing::warn!(link, "renew: the look failed; retried in {} s: {e}", retry_after(failures).as_secs()),
}
Err(e)
}
}
}
pub fn renew_ca_due(ca: &crate::mtls::InstallerCa, valid_for: Duration, now: i64) -> Result<Option<crate::mtls::InstallerCa>, String> {
if !crate::mtls::renewal_due(&ca.cert_pem, now).map_err(|e| format!("the installer CA certificate: {e}"))? {
return Ok(None);
}
ca.renew_ca(valid_for).map(Some).map_err(|e| format!("the installer CA could not be renewed: {e}"))
}
fn named(name: &str, why: String) -> String {
let l = why.to_ascii_lowercase();
if l.contains("expired") || l.contains("notvalidyet") || l.contains("not valid") {
return format!("REFUSED [front-renew-expired] `{name}`: its certificate has ended and was not renewed in time ({why}); re-pair it from the kept material");
}
format!("`{name}`: {why}")
}
pub fn renew_boxes(m: &crate::installer::Material, paths: Vec<(String, Box<dyn crate::installer_link::Path>)>, valid_for: Duration, now: i64) -> Result<String, String> {
let each = crate::installer::renew(m, paths, valid_for, now);
let renewed: Vec<&str> = each.iter().filter(|(_, r)| matches!(r, Ok(true))).map(|(n, _)| n.as_str()).collect();
let refused: Vec<String> = each.iter().filter_map(|(n, r)| r.as_ref().err().map(|e| named(n, e.clone()))).collect();
if !refused.is_empty() {
return Err(format!("{} renewed; refused: {}", if renewed.is_empty() { "none".to_string() } else { renewed.join(", ") }, refused.join("; ")));
}
Ok(if renewed.is_empty() { format!("{} box(es) looked at, none due", each.len()) } else { format!("renewed: {}", renewed.join(", ")) })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_retry_waits_double_from_ten_minutes_to_the_renewal_period() {
assert_eq!(retry_after(1), Duration::from_secs(600));
assert_eq!(retry_after(2), Duration::from_secs(1200));
assert_eq!(retry_after(3), Duration::from_secs(2400));
assert_eq!(retry_after(30), RENEW_EVERY);
}
#[test]
fn a_link_is_looked_at_on_its_schedule_backed_off_and_alerted() {
let dir = std::env::temp_dir().join(format!("np-renew-{}-{}", std::process::id(), line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let r = dir.as_path();
assert_eq!(look(r, "boxes", 1_000, || Ok("renewed: main".into())).unwrap(), Looked::Done("renewed: main".into()));
assert_eq!(look(r, "boxes", 1_001, || panic!("asked before the next look")).unwrap(), Looked::NotDue);
let next = 1_000 + RENEW_EVERY.as_secs() as i64;
for (n, at) in [(1u32, next), (2, next + 600), (3, next + 600 + 1200)] {
assert!(look(r, "boxes", at, || Err("the box did not answer".into())).is_err());
let s = State::load(r).links["boxes"].clone();
assert_eq!(s.failures, n);
assert_eq!(s.alert("boxes").is_some(), n >= ALERT_AFTER, "alert from the {ALERT_AFTER}th: {s:?}");
}
let at = next + 600 + 1200 + 2400;
assert_eq!(look(r, "boxes", at, || Ok("renewed: main".into())).unwrap(), Looked::Done("renewed: main".into()));
let s = State::load(r).links["boxes"].clone();
assert_eq!((s.failures, s.alert("boxes"), s.next), (0, None, at + RENEW_EVERY.as_secs() as i64));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_ended_certificate_is_named_for_a_re_pair() {
assert!(named("vm2", "certificate expired".into()).starts_with("REFUSED [front-renew-expired] `vm2`"));
assert_eq!(named("vm2", "the door did not answer".into()), "`vm2`: the door did not answer");
}
}