use serde::{Deserialize, Serialize};
use crate::error::CertmeshError;
use crate::roster::CertPolicy;
pub const DEFAULT_CA_MTLS_PORT: u16 = 5642;
pub const DEFAULT_CA_HTTP_PORT: u16 = 5641;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MemberState {
pub hostname: String,
pub ca_host: String,
#[serde(default = "default_mtls_port")]
pub ca_mtls_port: u16,
#[serde(default = "default_http_port")]
pub ca_http_port: u16,
pub ca_fingerprint: String,
#[serde(default)]
pub sans: Vec<String>,
#[serde(default)]
pub policy: CertPolicy,
#[serde(default)]
pub last_bundle_seq: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reload_hook: Option<String>,
}
fn default_mtls_port() -> u16 {
DEFAULT_CA_MTLS_PORT
}
fn default_http_port() -> u16 {
DEFAULT_CA_HTTP_PORT
}
impl MemberState {
pub fn ca_mtls_authority(&self) -> (String, u16) {
(self.ca_host.clone(), self.ca_mtls_port)
}
}
pub fn host_from_endpoint(endpoint: &str) -> String {
let after_scheme = endpoint
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(endpoint);
let authority = after_scheme
.split(['/', '?'])
.next()
.unwrap_or(after_scheme);
if let Some(rest) = authority.strip_prefix('[') {
if let Some((inside, _)) = rest.split_once(']') {
return inside.to_string();
}
}
match authority.rsplit_once(':') {
Some((host, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => {
host.to_string()
}
_ => authority.to_string(),
}
}
pub fn port_from_endpoint(endpoint: &str) -> u16 {
let after_scheme = endpoint
.split_once("://")
.map(|(_, rest)| rest)
.unwrap_or(endpoint);
let authority = after_scheme
.split(['/', '?'])
.next()
.unwrap_or(after_scheme);
let port_str = authority
.rsplit_once(']')
.map(|(_, rest)| rest.trim_start_matches(':'))
.or_else(|| authority.rsplit_once(':').map(|(_, p)| p))
.unwrap_or("");
port_str.parse().unwrap_or(DEFAULT_CA_HTTP_PORT)
}
pub fn load(path: &std::path::Path) -> Option<MemberState> {
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn save(path: &std::path::Path, state: &MemberState) -> Result<(), CertmeshError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_vec_pretty(state)
.map_err(|e| CertmeshError::Internal(format!("serialize member state: {e}")))?;
let tmp = path.with_extension(format!("json.tmp.{}", std::process::id()));
std::fs::write(&tmp, &json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> MemberState {
MemberState {
hostname: "web-01".to_string(),
ca_host: "ca-host".to_string(),
ca_mtls_port: 5642,
ca_http_port: 5641,
ca_fingerprint: "deadbeef".to_string(),
sans: vec!["web-01".to_string(), "web-01.local".to_string()],
policy: CertPolicy::default(),
last_bundle_seq: 0,
reload_hook: None,
}
}
#[test]
fn port_from_endpoint_parses_or_defaults() {
assert_eq!(port_from_endpoint("http://ca-host:5641"), 5641);
assert_eq!(port_from_endpoint("http://ca-host:9000/v1"), 9000);
assert_eq!(port_from_endpoint("http://ca-host"), DEFAULT_CA_HTTP_PORT);
assert_eq!(port_from_endpoint("192.168.1.55:5641"), 5641);
assert_eq!(port_from_endpoint("[::1]:5641"), 5641);
}
#[test]
fn host_from_endpoint_strips_scheme_and_port() {
assert_eq!(host_from_endpoint("http://ca-host:5641"), "ca-host");
assert_eq!(host_from_endpoint("https://ca-host:5641/v1"), "ca-host");
assert_eq!(host_from_endpoint("192.168.1.55:5641"), "192.168.1.55");
assert_eq!(host_from_endpoint("ca-host"), "ca-host");
assert_eq!(host_from_endpoint("http://ca-host"), "ca-host");
assert_eq!(host_from_endpoint("[::1]:5641"), "::1");
}
#[test]
fn save_then_load_round_trips() {
let dir = std::env::temp_dir().join(format!("koi-memberstate-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("member.json");
let state = sample();
save(&path, &state).unwrap();
let loaded = load(&path).expect("state loads back");
assert_eq!(loaded, state);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_absent_is_none() {
let path = std::path::Path::new("/nonexistent/koi/member.json");
assert!(load(path).is_none());
}
#[test]
fn ca_mtls_port_defaults_when_absent_in_json() {
let json = r#"{"hostname":"a","ca_host":"h","ca_fingerprint":"fp"}"#;
let parsed: MemberState = serde_json::from_str(json).unwrap();
assert_eq!(parsed.ca_mtls_port, DEFAULT_CA_MTLS_PORT);
assert!(parsed.sans.is_empty());
}
}