#[cfg(feature = "tls")]
pub mod certs {
use anyhow::{Result, anyhow};
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, KeyPair,
KeyUsagePurpose, SanType,
};
use std::fs;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, info};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct CertMeta {
pub sans: Vec<String>,
pub ca_expires_at: i64,
pub server_expires_at: i64,
pub generated_at: i64,
}
pub struct CertPaths {
pub dir: PathBuf,
pub ca_cert: PathBuf,
pub ca_key: PathBuf,
pub server_cert: PathBuf,
pub server_key: PathBuf,
pub meta: PathBuf,
}
impl CertPaths {
pub fn new(dir: &Path) -> Self {
Self {
dir: dir.to_path_buf(),
ca_cert: dir.join("ca.pem"),
ca_key: dir.join("ca-key.pem"),
server_cert: dir.join("server.pem"),
server_key: dir.join("server-key.pem"),
meta: dir.join("meta.json"),
}
}
}
pub fn needs_regeneration(paths: &CertPaths, renew_threshold_days: u32) -> bool {
if !paths.meta.exists() {
return true;
}
let meta = match load_meta(paths) {
Ok(m) => m,
Err(_) => return true,
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let threshold_secs = renew_threshold_days as i64 * 86400;
if meta.server_expires_at - now < threshold_secs {
info!("Server certificate expires soon, regenerating");
return true;
}
let current_sans = detect_sans();
if current_sans != meta.sans {
info!("SANs changed, regenerating certificates");
return true;
}
false
}
pub fn generate_certs(paths: &CertPaths) -> Result<()> {
fs::create_dir_all(&paths.dir)?;
let sans = detect_sans();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let ca_key = KeyPair::generate()?;
let mut ca_params = CertificateParams::default();
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut ca_dn = DistinguishedName::new();
ca_dn.push(DnType::CommonName, "LocalGPT CA");
ca_dn.push(DnType::OrganizationName, "LocalGPT");
ca_params.distinguished_name = ca_dn;
ca_params.not_after = rcgen::date_time_ymd(2036, 1, 1);
let ca_cert = ca_params.self_signed(&ca_key)?;
let server_key = KeyPair::generate()?;
let mut server_params = CertificateParams::default();
server_params.is_ca = IsCa::NoCa;
let mut server_dn = DistinguishedName::new();
server_dn.push(DnType::CommonName, "LocalGPT Server");
server_params.distinguished_name = server_dn;
server_params.not_after = rcgen::date_time_ymd(2027, 4, 11);
let san_types: Vec<SanType> = sans
.iter()
.map(|s| {
if let Ok(ip) = s.parse::<IpAddr>() {
SanType::IpAddress(ip)
} else {
SanType::DnsName(
s.clone()
.try_into()
.unwrap_or_else(|_| "localhost".to_string().try_into().unwrap()),
)
}
})
.collect();
server_params.subject_alt_names = san_types;
let server_cert = server_params.signed_by(&server_key, &ca_cert, &ca_key)?;
fs::write(&paths.ca_cert, ca_cert.pem())?;
fs::write(&paths.ca_key, ca_key.serialize_pem())?;
fs::write(&paths.server_cert, server_cert.pem())?;
fs::write(&paths.server_key, server_key.serialize_pem())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&paths.ca_key, fs::Permissions::from_mode(0o600))?;
fs::set_permissions(&paths.server_key, fs::Permissions::from_mode(0o600))?;
}
let ca_expires_at = now + 10 * 365 * 86400; let server_expires_at = now + 365 * 86400; let meta = CertMeta {
sans,
ca_expires_at,
server_expires_at,
generated_at: now,
};
let meta_json = serde_json::to_string_pretty(&meta)?;
fs::write(&paths.meta, meta_json)?;
info!("Generated TLS certificates at {}", paths.dir.display());
debug!("SANs: {:?}", meta.sans);
Ok(())
}
pub fn ensure_certs(cert_dir: &Path, renew_threshold_days: u32) -> Result<CertPaths> {
let paths = CertPaths::new(cert_dir);
if needs_regeneration(&paths, renew_threshold_days) {
generate_certs(&paths)?;
} else {
debug!(
"Using existing TLS certificates from {}",
cert_dir.display()
);
}
if !paths.server_cert.exists() || !paths.server_key.exists() {
return Err(anyhow!(
"TLS certificate files missing at {}",
cert_dir.display()
));
}
Ok(paths)
}
fn load_meta(paths: &CertPaths) -> Result<CertMeta> {
let content = fs::read_to_string(&paths.meta)?;
let meta: CertMeta = serde_json::from_str(&content)?;
Ok(meta)
}
fn detect_sans() -> Vec<String> {
let mut sans = vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"::1".to_string(),
];
if let Ok(hostname) = hostname::get() {
let hostname_str = hostname.to_string_lossy().to_string();
if !sans.contains(&hostname_str) {
sans.push(hostname_str);
}
}
if let Ok(addrs) = local_ip_address::list_afinet_netifas() {
for (_, ip) in addrs {
let ip_str = ip.to_string();
if !ip.is_loopback() && !sans.contains(&ip_str) {
sans.push(ip_str);
}
}
}
sans.sort();
sans
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_generate_and_load_certs() {
let dir = TempDir::new().unwrap();
let paths = CertPaths::new(dir.path());
generate_certs(&paths).unwrap();
assert!(paths.ca_cert.exists());
assert!(paths.ca_key.exists());
assert!(paths.server_cert.exists());
assert!(paths.server_key.exists());
assert!(paths.meta.exists());
let meta = load_meta(&paths).unwrap();
assert!(!meta.sans.is_empty());
assert!(meta.sans.contains(&"localhost".to_string()));
}
#[test]
fn test_needs_regeneration_first_run() {
let dir = TempDir::new().unwrap();
let paths = CertPaths::new(dir.path());
assert!(needs_regeneration(&paths, 30));
}
#[test]
fn test_no_regeneration_when_fresh() {
let dir = TempDir::new().unwrap();
let paths = CertPaths::new(dir.path());
generate_certs(&paths).unwrap();
assert!(!needs_regeneration(&paths, 30));
}
#[test]
fn test_ensure_certs_idempotent() {
let dir = TempDir::new().unwrap();
let paths = ensure_certs(dir.path(), 30).unwrap();
assert!(paths.server_cert.exists());
let paths2 = ensure_certs(dir.path(), 30).unwrap();
assert!(paths2.server_cert.exists());
}
#[test]
fn test_detect_sans() {
let sans = detect_sans();
assert!(sans.contains(&"localhost".to_string()));
assert!(sans.contains(&"127.0.0.1".to_string()));
}
}
}