use std::process::Command;
use chrono::{Duration, Utc};
use crate::audit;
use crate::ca::{self, CaState, IssuedCert};
use crate::certfiles;
use crate::error::CertmeshError;
use crate::protocol::HookResult;
use crate::roster::{MemberStatus, Roster, RosterMember};
pub const RENEWAL_CHECK_INTERVAL_SECS: u64 = 3600;
const RENEWAL_THRESHOLD_DAYS: i64 = 10;
pub fn members_needing_renewal(roster: &Roster) -> Vec<&RosterMember> {
let threshold = Utc::now() + Duration::days(RENEWAL_THRESHOLD_DAYS);
roster
.members
.iter()
.filter(|m| m.status == MemberStatus::Active && m.cert_expires <= threshold)
.collect()
}
pub fn renew_member_cert(ca: &CaState, member: &RosterMember) -> Result<IssuedCert, CertmeshError> {
ca::issue_certificate(ca, &member.hostname, &member.cert_sans)
}
pub fn write_renewed_cert_files(
paths: &crate::CertmeshPaths,
hostname: &str,
issued: &IssuedCert,
) -> Result<std::path::PathBuf, CertmeshError> {
certfiles::write_cert_files_to(&paths.certs_dir().join(hostname), issued)
.map_err(CertmeshError::Io)
}
pub fn execute_reload_hook(hook: &str) -> HookResult {
let parts: Vec<&str> = hook.split_whitespace().collect();
let result = if parts.is_empty() {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"empty hook command",
))
} else {
Command::new(parts[0]).args(&parts[1..]).output()
};
match result {
Ok(output) => {
let combined = String::from_utf8_lossy(&output.stdout).to_string()
+ &String::from_utf8_lossy(&output.stderr);
let trimmed = combined.trim().to_string();
HookResult {
success: output.status.success(),
command: hook.to_string(),
output: if trimmed.is_empty() {
None
} else {
Some(trimmed)
},
}
}
Err(e) => HookResult {
success: false,
command: hook.to_string(),
output: Some(e.to_string()),
},
}
}
pub fn renew_and_update_member(
ca: &CaState,
roster: &mut Roster,
hostname: &str,
paths: &crate::CertmeshPaths,
) -> Result<Option<HookResult>, CertmeshError> {
let member = roster
.find_member(hostname)
.ok_or_else(|| CertmeshError::RenewalFailed {
hostname: hostname.to_string(),
reason: "member not found in roster".to_string(),
})?;
let sans = member.cert_sans.clone();
let reload_hook = member.reload_hook.clone();
let issued = ca::issue_certificate(ca, hostname, &sans)?;
let cert_dir = certfiles::write_cert_files_to(&paths.certs_dir().join(hostname), &issued)?;
let member = roster
.find_member_mut(hostname)
.ok_or_else(|| CertmeshError::RenewalFailed {
hostname: hostname.to_string(),
reason: "member vanished during renewal".to_string(),
})?;
member.cert_fingerprint = issued.fingerprint.clone();
member.cert_expires = issued.expires;
member.cert_path = cert_dir.display().to_string();
let _ = audit::append_entry_to(
&paths.audit_log_path(),
"cert_renewed",
&[
("hostname", hostname),
("fingerprint", &issued.fingerprint),
("expires", &issued.expires.to_rfc3339()),
],
);
let hook_result = reload_hook.map(|hook| {
let result = execute_reload_hook(&hook);
if result.success {
tracing::info!(hostname, hook = %result.command, "Reload hook succeeded");
} else {
tracing::warn!(
hostname,
hook = %result.command,
output = ?result.output,
"Reload hook failed (cert files remain updated)"
);
}
let _ = audit::append_entry_to(
&paths.audit_log_path(),
"reload_hook_executed",
&[
("hostname", hostname),
("command", &result.command),
("success", if result.success { "true" } else { "false" }),
],
);
result
});
Ok(hook_result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ca;
use crate::profiles::TrustProfile;
use crate::roster::{MemberRole, MemberStatus, Roster, RosterMember};
use chrono::{Duration, Utc};
fn test_paths() -> crate::CertmeshPaths {
crate::CertmeshPaths::with_data_dir(koi_common::test::ensure_data_dir(
"koi-certmesh-lifecycle-tests",
))
}
fn make_test_ca() -> CaState {
ca::create_ca("test-pass", &[42u8; 32], &test_paths())
.unwrap()
.0
}
fn make_member(hostname: &str, expires_in_days: i64) -> RosterMember {
RosterMember {
hostname: hostname.to_string(),
role: MemberRole::Member,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp-placeholder".to_string(),
cert_expires: Utc::now() + Duration::days(expires_in_days),
cert_sans: vec![hostname.to_string(), format!("{hostname}.local")],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
}
}
#[test]
fn members_needing_renewal_filters_by_threshold() {
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.members.push(make_member("expiring-soon", 5));
roster.members.push(make_member("fresh-cert", 25));
roster.members.push(make_member("edge-case", 10));
let due = members_needing_renewal(&roster);
assert_eq!(due.len(), 2);
assert!(due.iter().any(|m| m.hostname == "expiring-soon"));
assert!(due.iter().any(|m| m.hostname == "edge-case"));
}
#[test]
fn members_needing_renewal_skips_revoked() {
let mut roster = Roster::new(TrustProfile::JustMe, None);
let mut member = make_member("revoked-host", 1);
member.status = MemberStatus::Revoked;
roster.members.push(member);
let due = members_needing_renewal(&roster);
assert!(due.is_empty());
}
#[test]
fn members_needing_renewal_empty_roster() {
let roster = Roster::new(TrustProfile::JustMe, None);
let due = members_needing_renewal(&roster);
assert!(due.is_empty());
}
#[test]
fn already_expired_cert_needs_renewal() {
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.members.push(make_member("already-expired", -2));
let due = members_needing_renewal(&roster);
assert_eq!(due.len(), 1);
assert_eq!(due[0].hostname, "already-expired");
}
#[test]
fn renew_member_cert_reuses_sans() {
let ca = make_test_ca();
let member = make_member("stone-05", 5);
let issued = renew_member_cert(&ca, &member).unwrap();
assert!(issued.cert_pem.contains("BEGIN CERTIFICATE"));
assert!(issued.key_pem.contains("BEGIN PRIVATE KEY"));
assert_eq!(issued.fingerprint.len(), 64);
let days_until_expiry = (issued.expires - Utc::now()).num_days();
assert!((29..=30).contains(&days_until_expiry));
}
#[cfg(unix)]
const TEST_ECHO_CMD: &str = "/bin/echo ok";
#[cfg(windows)]
const TEST_ECHO_CMD: &str = "C:\\Windows\\System32\\cmd.exe /c echo ok";
#[test]
fn execute_reload_hook_success() {
let result = execute_reload_hook(TEST_ECHO_CMD);
assert!(result.success, "hook failed: {:?}", result.output);
assert!(result.output.unwrap().contains("ok"));
}
#[test]
fn execute_reload_hook_failure() {
let cmd = if cfg!(windows) {
"cmd /C exit 1"
} else {
"exit 1"
};
let result = execute_reload_hook(cmd);
assert!(!result.success);
}
#[test]
fn execute_reload_hook_bad_command() {
let result = execute_reload_hook("this-command-definitely-does-not-exist-xyz-9999");
assert!(!result.success);
}
#[test]
fn renew_and_update_member_updates_roster() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.members.push(make_member("stone-05", 5));
let old_fp = roster.members[0].cert_fingerprint.clone();
let old_expires = roster.members[0].cert_expires;
let hook_result =
renew_and_update_member(&ca, &mut roster, "stone-05", &test_paths()).unwrap();
assert!(hook_result.is_none());
let member = roster.find_member("stone-05").unwrap();
assert_ne!(member.cert_fingerprint, old_fp);
assert!(member.cert_expires > old_expires);
}
#[test]
fn renew_and_update_member_not_found() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::JustMe, None);
let result = renew_and_update_member(&ca, &mut roster, "nonexistent", &test_paths());
assert!(matches!(result, Err(CertmeshError::RenewalFailed { .. })));
}
#[test]
fn renew_and_update_member_with_hook() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::JustMe, None);
let mut member = make_member("stone-05", 5);
#[cfg(unix)]
let cmd = "/bin/echo renewed";
#[cfg(windows)]
let cmd = "C:\\Windows\\System32\\cmd.exe /c echo renewed";
member.reload_hook = Some(cmd.to_string());
roster.members.push(member);
let hook_result =
renew_and_update_member(&ca, &mut roster, "stone-05", &test_paths()).unwrap();
assert!(hook_result.is_some());
let hr = hook_result.unwrap();
assert!(hr.success);
assert!(hr.output.unwrap().contains("renewed"));
}
#[test]
fn members_needing_renewal_all_members_due() {
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.members.push(make_member("host-a", 1));
roster.members.push(make_member("host-b", 3));
roster.members.push(make_member("host-c", 9));
let due = members_needing_renewal(&roster);
assert_eq!(due.len(), 3);
}
#[test]
fn members_needing_renewal_none_due() {
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.members.push(make_member("fresh-a", 20));
roster.members.push(make_member("fresh-b", 30));
let due = members_needing_renewal(&roster);
assert!(due.is_empty());
}
#[test]
fn members_needing_renewal_mixed_roles() {
let mut roster = Roster::new(TrustProfile::JustMe, None);
let mut primary = make_member("primary-host", 3);
primary.role = MemberRole::Primary;
let mut standby = make_member("standby-host", 3);
standby.role = MemberRole::Standby;
roster.members.push(primary);
roster.members.push(standby);
let due = members_needing_renewal(&roster);
assert_eq!(due.len(), 2);
}
#[test]
fn execute_reload_hook_empty_command() {
let result = execute_reload_hook("");
assert_eq!(result.command, "");
}
#[test]
fn execute_reload_hook_captures_stderr() {
#[cfg(unix)]
let cmd = "/bin/echo stderr_msg";
#[cfg(windows)]
let cmd = "C:\\Windows\\System32\\cmd.exe /c echo stderr_msg";
let result = execute_reload_hook(cmd);
assert!(result.success, "hook failed: {:?}", result.output);
assert!(result
.output
.as_deref()
.unwrap_or("")
.contains("stderr_msg"));
}
#[test]
fn renew_and_update_member_updates_cert_path() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::JustMe, None);
let mut member = make_member("stone-07", 5);
member.cert_path = "old/path".to_string();
roster.members.push(member);
renew_and_update_member(&ca, &mut roster, "stone-07", &test_paths()).unwrap();
let member = roster.find_member("stone-07").unwrap();
assert_ne!(member.cert_path, "old/path");
}
#[test]
fn renew_and_update_member_cert_expires_is_future() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.members.push(make_member("stone-08", 5));
renew_and_update_member(&ca, &mut roster, "stone-08", &test_paths()).unwrap();
let member = roster.find_member("stone-08").unwrap();
let days_until_expiry = (member.cert_expires - Utc::now()).num_days();
assert!(
days_until_expiry >= 29,
"cert should expire in ~30 days, got {days_until_expiry}"
);
}
#[test]
fn renew_and_update_member_fingerprint_is_sha256() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::JustMe, None);
roster.members.push(make_member("stone-09", 5));
renew_and_update_member(&ca, &mut roster, "stone-09", &test_paths()).unwrap();
let member = roster.find_member("stone-09").unwrap();
assert_eq!(member.cert_fingerprint.len(), 64);
assert!(member
.cert_fingerprint
.chars()
.all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn renew_member_cert_produces_distinct_fingerprints() {
let ca = make_test_ca();
let member = make_member("stone-10", 5);
let issued1 = renew_member_cert(&ca, &member).unwrap();
let issued2 = renew_member_cert(&ca, &member).unwrap();
assert_ne!(issued1.fingerprint, issued2.fingerprint);
assert_ne!(issued1.key_pem, issued2.key_pem);
}
#[test]
fn renew_member_cert_fullchain_contains_both_certs() {
let ca = make_test_ca();
let member = make_member("stone-11", 5);
let issued = renew_member_cert(&ca, &member).unwrap();
let cert_count = issued.fullchain_pem.matches("BEGIN CERTIFICATE").count();
assert_eq!(
cert_count, 2,
"fullchain should have exactly 2 certificates"
);
}
#[test]
fn renew_and_update_member_with_failing_hook_still_updates_roster() {
let ca = make_test_ca();
let mut roster = Roster::new(TrustProfile::JustMe, None);
let mut member = make_member("stone-12", 5);
let cmd = if cfg!(windows) {
"cmd /C exit 1"
} else {
"exit 1"
};
member.reload_hook = Some(cmd.to_string());
roster.members.push(member);
let old_fp = roster.members[0].cert_fingerprint.clone();
let hook_result =
renew_and_update_member(&ca, &mut roster, "stone-12", &test_paths()).unwrap();
assert!(hook_result.is_some());
assert!(!hook_result.unwrap().success);
let member = roster.find_member("stone-12").unwrap();
assert_ne!(member.cert_fingerprint, old_fp);
}
}