use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use koi_common::persist;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Roster {
pub metadata: RosterMetadata,
pub members: Vec<RosterMember>,
#[serde(default)]
pub revocation_list: Vec<RevokedMember>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, utoipa::ToSchema)]
pub struct CertPolicy {
pub leaf_lifetime_days: u32,
pub renew_threshold_days: u32,
pub grace_days: u32,
}
impl Default for CertPolicy {
fn default() -> Self {
Self {
leaf_lifetime_days: 90,
renew_threshold_days: 30,
grace_days: 14,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RosterMetadata {
pub created_at: DateTime<Utc>,
#[serde(default)]
pub enrollment_open: bool,
#[serde(default)]
pub requires_approval: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub operator: Option<String>,
#[serde(default)]
pub policy: CertPolicy,
#[serde(default)]
pub seq: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum EnrollmentState {
Open,
Closed,
}
impl EnrollmentState {
pub fn from_open(open: bool) -> Self {
if open {
Self::Open
} else {
Self::Closed
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum MemberRole {
Primary,
Standby,
Member,
Client,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum MemberStatus {
Active,
Revoked,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProxyConfigEntry {
pub name: String,
pub listen_port: u16,
pub backend: String,
#[serde(default)]
pub allow_remote: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RosterMember {
pub hostname: String,
pub role: MemberRole,
pub enrolled_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enrolled_by: Option<String>,
pub cert_fingerprint: String,
pub cert_expires: DateTime<Utc>,
pub cert_sans: Vec<String>,
#[serde(default, skip)]
pub cert_path: String,
pub status: MemberStatus,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub reload_hook: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub last_seen: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub pinned_ca_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub proxy_entries: Vec<ProxyConfigEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevokedMember {
pub hostname: String,
pub revoked_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revoked_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
const ROSTER_FILENAME: &str = "roster.json";
impl Roster {
pub fn empty() -> Self {
Self {
metadata: RosterMetadata {
created_at: Utc::now(),
enrollment_open: false,
requires_approval: false,
operator: None,
policy: CertPolicy::default(),
seq: 0,
},
members: Vec::new(),
revocation_list: Vec::new(),
}
}
pub fn new(enrollment_open: bool, requires_approval: bool, operator: Option<String>) -> Self {
Self {
metadata: RosterMetadata {
created_at: Utc::now(),
enrollment_open,
requires_approval,
operator,
policy: CertPolicy::default(),
seq: 0,
},
members: Vec::new(),
revocation_list: Vec::new(),
}
}
pub fn requires_approval(&self) -> bool {
self.metadata.requires_approval
}
pub fn is_enrollment_open(&self) -> bool {
self.metadata.enrollment_open
}
pub fn enrollment_state(&self) -> EnrollmentState {
EnrollmentState::from_open(self.metadata.enrollment_open)
}
pub fn open_enrollment(&mut self) {
self.metadata.enrollment_open = true;
}
pub fn close_enrollment(&mut self) {
self.metadata.enrollment_open = false;
}
pub fn find_member(&self, hostname: &str) -> Option<&RosterMember> {
self.members.iter().find(|m| m.hostname == hostname)
}
pub fn is_enrolled(&self, hostname: &str) -> bool {
self.members
.iter()
.any(|m| m.hostname == hostname && m.status == MemberStatus::Active)
}
pub fn is_revoked(&self, hostname: &str) -> bool {
self.revocation_list.iter().any(|r| r.hostname == hostname)
|| self
.members
.iter()
.any(|m| m.hostname == hostname && m.status == MemberStatus::Revoked)
}
pub fn revoke_member(
&mut self,
hostname: &str,
operator: Option<String>,
reason: Option<String>,
) -> Result<(), String> {
let member = self
.find_member_mut(hostname)
.ok_or_else(|| format!("member not found: {hostname}"))?;
if member.status == MemberStatus::Revoked {
return Ok(());
}
member.status = MemberStatus::Revoked;
self.revocation_list.push(RevokedMember {
hostname: hostname.to_string(),
revoked_at: Utc::now(),
revoked_by: operator,
reason,
});
Ok(())
}
pub fn active_count(&self) -> usize {
self.members
.iter()
.filter(|m| m.status == MemberStatus::Active)
.count()
}
pub fn primary(&self) -> Option<&RosterMember> {
self.members
.iter()
.find(|m| m.role == MemberRole::Primary && m.status == MemberStatus::Active)
}
pub fn standbys(&self) -> Vec<&RosterMember> {
self.members
.iter()
.filter(|m| m.role == MemberRole::Standby && m.status == MemberStatus::Active)
.collect()
}
pub fn find_member_mut(&mut self, hostname: &str) -> Option<&mut RosterMember> {
self.members.iter_mut().find(|m| m.hostname == hostname)
}
pub fn touch_member(&mut self, hostname: &str) {
if let Some(m) = self.find_member_mut(hostname) {
m.last_seen = Some(Utc::now());
}
}
}
pub fn roster_path(certmesh_dir: &Path) -> PathBuf {
certmesh_dir.join(ROSTER_FILENAME)
}
pub fn save_roster(roster: &Roster, path: &Path) -> Result<(), std::io::Error> {
persist::write_json_pretty(path, roster)?;
tracing::debug!(path = %path.display(), "Roster saved");
Ok(())
}
pub(crate) async fn persist_roster(
roster: &Roster,
path: &Path,
) -> Result<(), crate::error::CertmeshError> {
let roster_clone = roster.clone();
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || save_roster(&roster_clone, &path))
.await
.map_err(|e| std::io::Error::other(format!("roster save task: {e}")))
.and_then(|r| r)
.map_err(crate::error::CertmeshError::Io)
}
pub fn load_roster(path: &Path) -> Result<Roster, std::io::Error> {
persist::read_json(path)
}
#[cfg(test)]
mod tests {
use super::*;
const JUST_ME: (bool, bool) = (true, false);
const MY_TEAM: (bool, bool) = (true, true);
const MY_ORG: (bool, bool) = (false, true);
#[test]
fn new_roster_just_me_is_open() {
let r = Roster::new(JUST_ME.0, JUST_ME.1, None);
assert!(r.metadata.enrollment_open);
assert!(!r.metadata.requires_approval);
assert_eq!(r.enrollment_state(), EnrollmentState::Open);
assert!(r.metadata.operator.is_none());
assert!(r.members.is_empty());
assert!(r.revocation_list.is_empty());
}
#[test]
fn new_roster_organization_closed() {
let r = Roster::new(MY_ORG.0, MY_ORG.1, Some("Admin".to_string()));
assert!(!r.metadata.enrollment_open);
assert!(r.metadata.requires_approval);
assert_eq!(r.enrollment_state(), EnrollmentState::Closed);
assert_eq!(r.metadata.operator.as_deref(), Some("Admin"));
}
#[test]
fn metadata_bools_round_trip_through_json() {
let r = Roster::new(MY_TEAM.0, MY_TEAM.1, Some("Alice".to_string()));
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("\"enrollment_open\":true"));
assert!(json.contains("\"requires_approval\":true"));
let back: Roster = serde_json::from_str(&json).unwrap();
assert!(back.metadata.enrollment_open);
assert!(back.metadata.requires_approval);
assert_eq!(back.metadata.operator.as_deref(), Some("Alice"));
}
#[test]
fn roster_serde_round_trip() {
let mut r = Roster::new(MY_TEAM.0, MY_TEAM.1, Some("Alice".to_string()));
r.members.push(RosterMember {
hostname: "node-01".to_string(),
role: MemberRole::Primary,
enrolled_at: Utc::now(),
enrolled_by: Some("Alice".to_string()),
cert_fingerprint: "abc123".to_string(),
cert_expires: Utc::now(),
cert_sans: vec!["node-01".to_string(), "node-01.local".to_string()],
cert_path: "/home/koi/.koi/certs/node-01".to_string(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: Some(Utc::now()),
pinned_ca_fingerprint: Some("cafp123".to_string()),
proxy_entries: Vec::new(),
});
let json = serde_json::to_string(&r).unwrap();
let deserialized: Roster = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.members.len(), 1);
assert_eq!(deserialized.members[0].hostname, "node-01");
assert_eq!(deserialized.members[0].role, MemberRole::Primary);
}
#[test]
fn save_and_load_roster() {
let dir = std::env::temp_dir().join("koi-certmesh-test-roster");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("roster.json");
let r = Roster::new(JUST_ME.0, JUST_ME.1, None);
save_roster(&r, &path).unwrap();
let loaded = load_roster(&path).unwrap();
assert!(loaded.metadata.enrollment_open);
assert!(!loaded.metadata.requires_approval);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_and_count_members() {
let mut r = Roster::new(JUST_ME.0, JUST_ME.1, None);
assert_eq!(r.active_count(), 0);
assert!(!r.is_enrolled("node-01"));
r.members.push(RosterMember {
hostname: "node-01".to_string(),
role: MemberRole::Primary,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "abc".to_string(),
cert_expires: Utc::now(),
cert_sans: vec![],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
assert_eq!(r.active_count(), 1);
assert!(r.is_enrolled("node-01"));
assert!(r.find_member("node-01").is_some());
assert!(r.find_member("node-99").is_none());
}
#[test]
fn backward_compat_deserialize_without_new_fields() {
let json = r#"{
"metadata": {
"created_at": "2026-02-01T00:00:00Z",
"enrollment_open": true,
"requires_approval": false
},
"members": [{
"hostname": "old-host",
"role": "primary",
"enrolled_at": "2026-02-01T00:00:00Z",
"cert_fingerprint": "abc",
"cert_expires": "2026-03-01T00:00:00Z",
"cert_sans": ["old-host"],
"cert_path": "/certs/old-host",
"status": "active"
}]
}"#;
let r: Roster = serde_json::from_str(json).unwrap();
assert_eq!(r.members.len(), 1);
assert!(r.metadata.enrollment_open);
assert!(!r.metadata.requires_approval);
assert!(r.members[0].reload_hook.is_none());
assert!(r.members[0].last_seen.is_none());
assert!(r.members[0].pinned_ca_fingerprint.is_none());
}
#[test]
fn standby_role_serde() {
let json = r#""standby""#;
let role: MemberRole = serde_json::from_str(json).unwrap();
assert_eq!(role, MemberRole::Standby);
let serialized = serde_json::to_string(&MemberRole::Standby).unwrap();
assert_eq!(serialized, r#""standby""#);
}
#[test]
fn primary_and_standbys_helpers() {
let mut r = Roster::new(JUST_ME.0, JUST_ME.1, None);
assert!(r.primary().is_none());
assert!(r.standbys().is_empty());
let make_member = |hostname: &str, role: MemberRole| RosterMember {
hostname: hostname.to_string(),
role,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp".to_string(),
cert_expires: Utc::now(),
cert_sans: vec![],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
};
r.members.push(make_member("node-01", MemberRole::Primary));
r.members.push(make_member("node-02", MemberRole::Standby));
r.members.push(make_member("node-03", MemberRole::Member));
r.members.push(make_member("node-04", MemberRole::Standby));
assert_eq!(r.primary().unwrap().hostname, "node-01");
let standbys = r.standbys();
assert_eq!(standbys.len(), 2);
assert!(standbys.iter().any(|m| m.hostname == "node-02"));
assert!(standbys.iter().any(|m| m.hostname == "node-04"));
}
#[test]
fn find_member_mut_and_touch() {
let mut r = Roster::new(JUST_ME.0, JUST_ME.1, None);
r.members.push(RosterMember {
hostname: "node-01".to_string(),
role: MemberRole::Primary,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp".to_string(),
cert_expires: Utc::now(),
cert_sans: vec![],
cert_path: String::new(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
});
assert!(r.members[0].last_seen.is_none());
r.touch_member("node-01");
assert!(r.members[0].last_seen.is_some());
r.touch_member("nonexistent");
let m = r.find_member_mut("node-01").unwrap();
m.reload_hook = Some("systemctl restart nginx".to_string());
assert_eq!(
r.members[0].reload_hook.as_deref(),
Some("systemctl restart nginx"),
);
}
#[test]
fn open_and_close_enrollment() {
let mut r = Roster::new(MY_ORG.0, MY_ORG.1, Some("Admin".into()));
assert!(!r.is_enrollment_open());
assert_eq!(r.enrollment_state(), EnrollmentState::Closed);
r.open_enrollment();
assert!(r.is_enrollment_open());
assert_eq!(r.enrollment_state(), EnrollmentState::Open);
r.close_enrollment();
assert!(!r.is_enrollment_open());
assert_eq!(r.enrollment_state(), EnrollmentState::Closed);
}
#[test]
fn new_fields_skip_serialization_when_none() {
let member = RosterMember {
hostname: "node-01".to_string(),
role: MemberRole::Primary,
enrolled_at: Utc::now(),
enrolled_by: None,
cert_fingerprint: "fp".to_string(),
cert_expires: Utc::now(),
cert_sans: vec![],
cert_path: "/var/lib/koi/certs/node-01".to_string(),
status: MemberStatus::Active,
reload_hook: None,
last_seen: None,
pinned_ca_fingerprint: None,
proxy_entries: Vec::new(),
};
let json = serde_json::to_string(&member).unwrap();
assert!(!json.contains("reload_hook"));
assert!(!json.contains("last_seen"));
assert!(!json.contains("pinned_ca_fingerprint"));
assert!(!json.contains("cert_path"));
assert!(!json.contains("/var/lib/koi/certs"));
}
}