use std::collections::HashMap;
use std::fs;
use std::net::IpAddr;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType,
PKCS_ECDSA_P256_SHA256,
};
use rustls_pki_types::{CertificateDer, PrivateKeyDer};
use time::OffsetDateTime;
const CA_VALIDITY_DAYS: i64 = 365 * 30;
const CA_EXPIRY_WARNING_DAYS: i64 = 365;
const LEAF_VALIDITY_DAYS: i64 = 365 * 20;
const LEAF_REISSUE_THRESHOLD_DAYS: i64 = 365;
const ACME_RENEW_THRESHOLD_DAYS: i64 = 30;
const ACME_RENEW_INTERVAL: Duration = Duration::from_secs(24 * 3600);
pub type AcmeChallenges = Arc<Mutex<HashMap<String, String>>>;
pub fn new_acme_challenges() -> AcmeChallenges {
Arc::new(Mutex::new(HashMap::new()))
}
#[derive(Debug, Clone)]
pub struct CertPaths {
pub cert: PathBuf,
pub key: PathBuf,
#[allow(dead_code)]
pub ca_cert: Option<PathBuf>,
}
pub fn acme_mode_enabled(tls: &crate::config::TlsConfig) -> bool {
tls.acme_domains.iter().any(|d| !d.trim().is_empty())
}
pub async fn ensure_certs(
config_dir: &Path,
tls: &crate::config::TlsConfig,
acme_challenges: Option<AcmeChallenges>,
) -> Result<CertPaths> {
if acme_mode_enabled(tls) {
let challenges = acme_challenges
.ok_or_else(|| anyhow!("ACME mode enabled but no challenge handle provided"))?;
return ensure_acme(config_dir, tls, challenges).await;
}
ensure_ca_mode(config_dir, &tls.hosts)
}
pub fn load_rustls_config(cert_path: &Path, key_path: &Path) -> Result<rustls::ServerConfig> {
let cert_pem = fs::read(cert_path).context("reading cert file")?;
let key_pem = fs::read(key_path).context("reading key file")?;
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut &cert_pem[..])
.collect::<std::result::Result<Vec<_>, _>>()
.context("parsing cert PEM")?;
let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut &key_pem[..])
.context("parsing key PEM")?
.context("no private key found in PEM")?;
let config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.context("building rustls ServerConfig")?;
Ok(config)
}
pub fn ca_cert_path(config_dir: &Path) -> PathBuf {
config_dir.join("ca.crt")
}
fn ca_key_path(config_dir: &Path) -> PathBuf {
config_dir.join("ca.key")
}
fn leaf_cert_path(config_dir: &Path) -> PathBuf {
config_dir.join("leaf.crt")
}
fn leaf_key_path(config_dir: &Path) -> PathBuf {
config_dir.join("leaf.key")
}
fn acme_expiry_path(config_dir: &Path) -> PathBuf {
acme_dir(config_dir).join("cert.expiry")
}
fn acme_dir(config_dir: &Path) -> PathBuf {
config_dir.join("acme")
}
fn acme_account_path(config_dir: &Path) -> PathBuf {
acme_dir(config_dir).join("account.json")
}
fn acme_cert_path(config_dir: &Path) -> PathBuf {
acme_dir(config_dir).join("cert.pem")
}
fn acme_key_path(config_dir: &Path) -> PathBuf {
acme_dir(config_dir).join("key.pem")
}
struct CaMaterial {
cert: rcgen::Certificate,
key: KeyPair,
pem: String,
not_after: OffsetDateTime,
}
fn ensure_ca_mode(config_dir: &Path, extra_hosts: &[String]) -> Result<CertPaths> {
ensure_ca_mode_for_hosts(config_dir, &collect_hosts(extra_hosts))?;
Ok(CertPaths {
cert: leaf_cert_path(config_dir),
key: leaf_key_path(config_dir),
ca_cert: Some(ca_cert_path(config_dir)),
})
}
fn ensure_ca_mode_for_hosts(config_dir: &Path, hosts: &[String]) -> Result<()> {
fs::create_dir_all(config_dir)
.with_context(|| format!("creating config dir: {}", config_dir.display()))?;
let (ca, ca_created) = ensure_ca(config_dir)?;
warn_if_ca_expiring(&ca);
let status = if ca_created {
LeafStatus::Reissue("a new CA was generated".to_string())
} else {
inspect_leaf(config_dir, hosts)
};
match status {
LeafStatus::Reuse { remaining_days } => {
eprintln!(
"[ssl] Reusing leaf cert at {} ({} day(s) remaining)",
leaf_cert_path(config_dir).display(),
remaining_days
);
Ok(())
}
LeafStatus::Reissue(reason) => {
eprintln!("[ssl] Issuing leaf cert: {reason}");
issue_leaf(
config_dir,
&ca,
hosts,
time::Duration::days(LEAF_VALIDITY_DAYS),
)
}
}
}
fn ensure_ca(config_dir: &Path) -> Result<(CaMaterial, bool)> {
let cert_path = ca_cert_path(config_dir);
let key_path = ca_key_path(config_dir);
if cert_path.exists() && key_path.exists() {
let key_pem = fs::read_to_string(&key_path)
.with_context(|| format!("reading {}", key_path.display()))?;
let pem = fs::read_to_string(&cert_path)
.with_context(|| format!("reading {}", cert_path.display()))?;
let key = KeyPair::from_pem(&key_pem).context("parsing CA key PEM")?;
let params = CertificateParams::from_ca_cert_pem(&pem).context("parsing CA cert PEM")?;
let not_after = params.not_after;
let cert = params.self_signed(&key).context("re-binding CA cert")?;
return Ok((
CaMaterial {
cert,
key,
pem,
not_after,
},
false,
));
}
eprintln!(
"[ssl] Generating new local root CA at {}",
config_dir.display()
);
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).context("generating CA key")?;
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::CommonName, "mobux local CA");
params
.distinguished_name
.push(DnType::OrganizationName, "mobux");
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
KeyUsagePurpose::DigitalSignature,
];
let now = OffsetDateTime::now_utc();
let not_after = now
.checked_add(time::Duration::days(CA_VALIDITY_DAYS))
.ok_or_else(|| anyhow!("CA not_after overflow"))?;
params.not_before = now;
params.not_after = not_after;
let cert = params.self_signed(&key).context("self-signing CA cert")?;
let pem = cert.pem();
write_secret(&key_path, key.serialize_pem().as_bytes())?;
fs::write(&cert_path, &pem).context("writing CA cert")?;
eprintln!("[ssl] CA written: {}", cert_path.display());
eprintln!("[ssl] CA key: {} (mode 0600)", key_path.display());
Ok((
CaMaterial {
cert,
key,
pem,
not_after,
},
true,
))
}
fn warn_if_ca_expiring(ca: &CaMaterial) {
let remaining = (ca.not_after - OffsetDateTime::now_utc()).whole_days();
if remaining >= CA_EXPIRY_WARNING_DAYS {
return;
}
eprintln!(
"[ssl] WARNING: the local root CA expires in {remaining} day(s) ({}). \
mobux will not replace it: delete ca.crt and ca.key to generate a new one, \
then reinstall it on every device from the install page.",
ca.not_after.date()
);
}
#[derive(Debug, PartialEq)]
enum LeafStatus {
Reuse { remaining_days: i64 },
Reissue(String),
}
fn inspect_leaf(config_dir: &Path, hosts: &[String]) -> LeafStatus {
let Ok(pem) = fs::read_to_string(leaf_cert_path(config_dir)) else {
return LeafStatus::Reissue("no leaf cert found".to_string());
};
let Ok(params) = CertificateParams::from_ca_cert_pem(&pem) else {
return LeafStatus::Reissue("the existing leaf cert is unreadable".to_string());
};
let Ok(key_pem) = fs::read_to_string(leaf_key_path(config_dir)) else {
return LeafStatus::Reissue("no leaf key found".to_string());
};
let Ok(key) = KeyPair::from_pem(&key_pem) else {
return LeafStatus::Reissue("the existing leaf key is unreadable".to_string());
};
if leaf_public_key_der(&pem).as_deref() != Some(key.public_key_der().as_slice()) {
return LeafStatus::Reissue("the existing leaf key does not match its cert".to_string());
}
let remaining_days = (params.not_after - OffsetDateTime::now_utc()).whole_days();
if remaining_days < LEAF_REISSUE_THRESHOLD_DAYS {
return LeafStatus::Reissue(format!(
"the existing leaf expires in {remaining_days} day(s)"
));
}
let covered: Vec<String> = params
.subject_alt_names
.iter()
.filter_map(|san| match san {
SanType::DnsName(name) => Some(name.as_str().to_ascii_lowercase()),
SanType::IpAddress(ip) => Some(ip.to_string()),
_ => None,
})
.collect();
let missing: Vec<String> = hosts
.iter()
.map(|h| normalize_host(h))
.filter(|h| !covered.contains(h))
.collect();
if !missing.is_empty() {
return LeafStatus::Reissue(format!(
"the existing leaf does not cover {}",
missing.join(", ")
));
}
LeafStatus::Reuse { remaining_days }
}
fn leaf_public_key_der(pem: &str) -> Option<Vec<u8>> {
let (_, block) = x509_parser::pem::parse_x509_pem(pem.as_bytes()).ok()?;
let cert = block.parse_x509().ok()?;
Some(cert.public_key().raw.to_vec())
}
fn normalize_host(host: &str) -> String {
match host.parse::<IpAddr>() {
Ok(ip) => ip.to_string(),
Err(_) => host.to_ascii_lowercase(),
}
}
fn issue_leaf(
config_dir: &Path,
ca: &CaMaterial,
hosts: &[String],
validity: time::Duration,
) -> Result<()> {
let sans = build_sans(hosts)?;
let san_display: Vec<String> = sans
.iter()
.map(|s| match s {
SanType::DnsName(n) => n.to_string(),
SanType::IpAddress(ip) => ip.to_string(),
_ => "?".to_string(),
})
.collect();
eprintln!("[ssl] SANs: {}", san_display.join(", "));
eprintln!("[ssl] Validity: {} days", validity.whole_days());
let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).context("generating leaf key")?;
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::CommonName, "mobux server");
params.subject_alt_names = sans;
params.is_ca = IsCa::NoCa;
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
params.extended_key_usages = vec![
rcgen::ExtendedKeyUsagePurpose::ServerAuth,
rcgen::ExtendedKeyUsagePurpose::ClientAuth,
];
let now = OffsetDateTime::now_utc();
params.not_before = now;
params.not_after = now
.checked_add(validity)
.ok_or_else(|| anyhow!("leaf not_after overflow"))?
.min(ca.not_after);
let leaf = params
.signed_by(&leaf_key, &ca.cert, &ca.key)
.context("signing leaf cert with CA")?;
let mut chain = leaf.pem();
chain.push('\n');
chain.push_str(&ca.pem);
let key_path = leaf_key_path(config_dir);
let cert_path = leaf_cert_path(config_dir);
let key_tmp = key_path.with_extension("key.tmp");
let cert_tmp = cert_path.with_extension("crt.tmp");
write_secret(&key_tmp, leaf_key.serialize_pem().as_bytes())?;
fs::write(&cert_tmp, chain).context("writing leaf cert")?;
fs::rename(&key_tmp, &key_path).context("moving leaf key into place")?;
fs::rename(&cert_tmp, &cert_path).context("moving leaf cert into place")?;
for sidecar in ["leaf.expiry", "leaf.meta"] {
remove_if_present(&config_dir.join(sidecar))?;
}
Ok(())
}
fn remove_if_present(path: &Path) -> Result<()> {
match fs::remove_file(path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
Err(e).with_context(|| format!("removing {}", path.display()))
}
_ => Ok(()),
}
}
fn collect_hosts(extra_hosts: &[String]) -> Vec<String> {
let mut out: Vec<String> = vec![
"localhost".into(),
"127.0.0.1".into(),
"::1".into(),
"0.0.0.0".into(),
];
if let Ok(hn) = hostname::get() {
if let Some(s) = hn.to_str() {
out.push(s.to_string());
}
}
for h in extra_hosts {
let h = h.trim();
if !h.is_empty() {
out.push(h.to_string());
}
}
out.sort();
out.dedup();
out
}
fn build_sans(hosts: &[String]) -> Result<Vec<SanType>> {
let mut sans: Vec<SanType> = Vec::with_capacity(hosts.len());
for h in hosts {
if let Ok(ip) = h.parse::<IpAddr>() {
sans.push(SanType::IpAddress(ip));
} else {
let name: rcgen::Ia5String = h
.as_str()
.try_into()
.with_context(|| format!("invalid DNS SAN: {h}"))?;
sans.push(SanType::DnsName(name));
}
}
Ok(sans)
}
fn write_secret(path: &Path, contents: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating dir: {}", parent.display()))?;
}
fs::write(path, contents).with_context(|| format!("writing {}", path.display()))?;
let mut perms = fs::metadata(path)
.with_context(|| format!("stat {}", path.display()))?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms).with_context(|| format!("chmod 0600 {}", path.display()))?;
Ok(())
}
fn remaining_days_from_sidecar(expiry_path: &Path) -> Option<i64> {
let raw = fs::read_to_string(expiry_path).ok()?;
let ts: i64 = raw.trim().parse().ok()?;
let now = OffsetDateTime::now_utc().unix_timestamp();
Some((ts - now) / 86400)
}
async fn ensure_acme(
config_dir: &Path,
tls: &crate::config::TlsConfig,
challenges: AcmeChallenges,
) -> Result<CertPaths> {
let dir = acme_dir(config_dir);
fs::create_dir_all(&dir).with_context(|| format!("creating ACME dir: {}", dir.display()))?;
let domains = parse_acme_domains(tls)?;
let email = tls.acme_email.trim();
if email.is_empty() {
return Err(anyhow!(
"tls.acme_email is required when tls.acme_domains is set (env: MOBUX_ACME_EMAIL)"
));
}
let directory = tls.acme_directory.clone();
let cert_path = acme_cert_path(config_dir);
let key_path = acme_key_path(config_dir);
let remaining = remaining_days_from_sidecar(&acme_expiry_path(config_dir)).unwrap_or(-1);
let need_obtain =
!cert_path.exists() || !key_path.exists() || remaining <= ACME_RENEW_THRESHOLD_DAYS;
if need_obtain {
obtain_acme_cert(config_dir, &domains, email, &directory, challenges.clone()).await?;
} else {
eprintln!(
"[ssl] ACME cert at {} valid for {} more day(s)",
cert_path.display(),
remaining
);
}
spawn_renewal_task(
config_dir.to_path_buf(),
domains,
email.to_string(),
directory,
challenges,
);
Ok(CertPaths {
cert: cert_path,
key: key_path,
ca_cert: None,
})
}
fn parse_acme_domains(tls: &crate::config::TlsConfig) -> Result<Vec<String>> {
let domains: Vec<String> = tls
.acme_domains
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if domains.is_empty() {
return Err(anyhow!("tls.acme_domains contains no domains"));
}
Ok(domains)
}
async fn obtain_acme_cert(
config_dir: &Path,
domains: &[String],
email: &str,
directory: &str,
challenges: AcmeChallenges,
) -> Result<()> {
use instant_acme::{
AuthorizationStatus, ChallengeType, Identifier, NewOrder, OrderStatus, RetryPolicy,
};
eprintln!(
"[ssl] ACME: requesting cert for {} via {}",
domains.join(", "),
directory
);
let contact = format!("mailto:{email}");
let account = load_or_create_account(config_dir, directory, &contact).await?;
let identifiers: Vec<Identifier> = domains.iter().map(|d| Identifier::Dns(d.clone())).collect();
let mut order = account
.new_order(&NewOrder::new(&identifiers))
.await
.context("creating ACME order")?;
let mut authorizations = order.authorizations();
let mut tokens_added: Vec<String> = Vec::new();
while let Some(result) = authorizations.next().await {
let mut authz = result.context("fetching authorization")?;
match authz.status {
AuthorizationStatus::Pending => {}
AuthorizationStatus::Valid => continue,
other => return Err(anyhow!("unexpected authorization status: {other:?}")),
}
let mut challenge = authz
.challenge(ChallengeType::Http01)
.ok_or_else(|| anyhow!("ACME server did not offer http-01 challenge"))?;
let key_auth = challenge.key_authorization();
let token = challenge.token.clone();
let value = key_auth.as_str().to_string();
challenges
.lock()
.map_err(|_| anyhow!("acme challenges mutex poisoned"))?
.insert(token.clone(), value);
tokens_added.push(token);
challenge
.set_ready()
.await
.context("marking ACME challenge ready")?;
}
let status = order
.poll_ready(&RetryPolicy::default())
.await
.context("polling ACME order ready")?;
if status != OrderStatus::Ready {
return Err(anyhow!("ACME order did not become ready: {status:?}"));
}
let private_key_pem = order.finalize().await.context("finalizing ACME order")?;
let cert_chain_pem = order
.poll_certificate(&RetryPolicy::default())
.await
.context("polling ACME certificate")?;
write_secret(&acme_key_path(config_dir), private_key_pem.as_bytes())?;
fs::write(acme_cert_path(config_dir), cert_chain_pem).context("writing ACME cert")?;
let assumed_validity_days: i64 = 90;
let expiry = OffsetDateTime::now_utc().unix_timestamp() + (assumed_validity_days - 1) * 86400;
fs::write(acme_expiry_path(config_dir), expiry.to_string())
.context("writing ACME expiry sidecar")?;
if let Ok(mut map) = challenges.lock() {
for tok in tokens_added {
map.remove(&tok);
}
}
eprintln!(
"[ssl] ACME: cert installed at {}",
acme_cert_path(config_dir).display()
);
Ok(())
}
async fn load_or_create_account(
config_dir: &Path,
directory: &str,
contact: &str,
) -> Result<instant_acme::Account> {
use instant_acme::{Account, NewAccount};
let creds_path = acme_account_path(config_dir);
if creds_path.exists() {
let raw = fs::read_to_string(&creds_path)
.with_context(|| format!("reading {}", creds_path.display()))?;
let creds: instant_acme::AccountCredentials =
serde_json::from_str(&raw).context("parsing ACME account credentials")?;
let account = Account::builder()?
.from_credentials(creds)
.await
.context("loading ACME account from credentials")?;
return Ok(account);
}
let (account, credentials) = Account::builder()?
.create(
&NewAccount {
contact: &[contact],
terms_of_service_agreed: true,
only_return_existing: false,
},
directory.to_string(),
None,
)
.await
.context("creating ACME account")?;
let json =
serde_json::to_string_pretty(&credentials).context("serializing ACME credentials")?;
write_secret(&creds_path, json.as_bytes())?;
Ok(account)
}
fn spawn_renewal_task(
config_dir: PathBuf,
domains: Vec<String>,
email: String,
directory: String,
challenges: AcmeChallenges,
) {
tokio::spawn(async move {
loop {
tokio::time::sleep(ACME_RENEW_INTERVAL).await;
let remaining =
remaining_days_from_sidecar(&acme_expiry_path(&config_dir)).unwrap_or(-1);
if remaining > ACME_RENEW_THRESHOLD_DAYS {
continue;
}
eprintln!(
"[ssl] ACME: cert has {} day(s) remaining; renewing",
remaining
);
if let Err(e) = obtain_acme_cert(
&config_dir,
&domains,
&email,
&directory,
challenges.clone(),
)
.await
{
eprintln!("[ssl] ACME renewal failed: {e:#}");
}
}
});
}
pub fn lookup_acme_challenge(challenges: &AcmeChallenges, token: &str) -> Option<String> {
challenges.lock().ok().and_then(|m| m.get(token).cloned())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TlsConfig;
#[test]
fn acme_mode_follows_the_configured_domains() {
let mut tls = TlsConfig::default();
assert!(!acme_mode_enabled(&tls), "no domains means CA mode");
tls.acme_domains = vec![" ".to_string()];
assert!(!acme_mode_enabled(&tls), "a blank domain states nothing");
tls.acme_domains = vec!["mobux.example".to_string()];
assert!(acme_mode_enabled(&tls));
}
#[test]
fn acme_domains_are_trimmed_and_blanks_dropped() {
let tls = TlsConfig {
acme_domains: vec![" a.example ".to_string(), String::new(), "b.example".into()],
..TlsConfig::default()
};
assert_eq!(
parse_acme_domains(&tls).unwrap(),
vec!["a.example".to_string(), "b.example".to_string()]
);
}
#[test]
fn every_cert_path_hangs_off_the_config_dir_it_is_given() {
let dir = Path::new("/somewhere/mobux");
assert_eq!(ca_cert_path(dir), dir.join("ca.crt"));
assert_eq!(leaf_cert_path(dir), dir.join("leaf.crt"));
assert_eq!(acme_cert_path(dir), dir.join("acme/cert.pem"));
}
fn hosts(names: &[&str]) -> Vec<String> {
names.iter().map(|h| h.to_string()).collect()
}
fn read_cert(path: &Path) -> CertificateParams {
CertificateParams::from_ca_cert_pem(&fs::read_to_string(path).unwrap()).unwrap()
}
fn validity_days(params: &CertificateParams) -> i64 {
(params.not_after - params.not_before).whole_days()
}
fn leaf_remaining_days(dir: &Path) -> i64 {
(read_cert(&leaf_cert_path(dir)).not_after - OffsetDateTime::now_utc()).whole_days()
}
#[test]
fn a_fresh_issue_gives_a_30_year_ca_and_a_20_year_leaf() {
let dir = tempfile::tempdir().unwrap();
ensure_ca_mode_for_hosts(dir.path(), &hosts(&["localhost", "127.0.0.1"])).unwrap();
assert_eq!(
validity_days(&read_cert(&ca_cert_path(dir.path()))),
365 * 30
);
assert_eq!(
validity_days(&read_cert(&leaf_cert_path(dir.path()))),
365 * 20
);
}
#[test]
fn a_leaf_expiring_in_six_months_is_reissued_from_the_same_ca() {
let dir = tempfile::tempdir().unwrap();
let wanted = hosts(&["localhost", "127.0.0.1"]);
let (ca, _) = ensure_ca(dir.path()).unwrap();
issue_leaf(dir.path(), &ca, &wanted, time::Duration::days(180)).unwrap();
let ca_bytes = fs::read(ca_cert_path(dir.path())).unwrap();
let ca_key_bytes = fs::read(ca_key_path(dir.path())).unwrap();
ensure_ca_mode_for_hosts(dir.path(), &wanted).unwrap();
assert!(leaf_remaining_days(dir.path()) > 365 * 19);
assert_eq!(fs::read(ca_cert_path(dir.path())).unwrap(), ca_bytes);
assert_eq!(fs::read(ca_key_path(dir.path())).unwrap(), ca_key_bytes);
}
#[test]
fn a_leaf_with_twenty_years_left_is_reused() {
let dir = tempfile::tempdir().unwrap();
let wanted = hosts(&["localhost", "127.0.0.1", "::1"]);
ensure_ca_mode_for_hosts(dir.path(), &wanted).unwrap();
let leaf_bytes = fs::read(leaf_cert_path(dir.path())).unwrap();
assert!(matches!(
inspect_leaf(dir.path(), &wanted),
LeafStatus::Reuse { .. }
));
ensure_ca_mode_for_hosts(dir.path(), &wanted).unwrap();
assert_eq!(fs::read(leaf_cert_path(dir.path())).unwrap(), leaf_bytes);
}
#[test]
fn a_leaf_missing_a_current_host_is_reissued() {
let dir = tempfile::tempdir().unwrap();
ensure_ca_mode_for_hosts(dir.path(), &hosts(&["localhost", "old-host"])).unwrap();
let ca_bytes = fs::read(ca_cert_path(dir.path())).unwrap();
let wanted = hosts(&["localhost", "new-host.tailnet.ts.net", "100.64.0.7"]);
assert_eq!(
inspect_leaf(dir.path(), &wanted),
LeafStatus::Reissue(
"the existing leaf does not cover new-host.tailnet.ts.net, 100.64.0.7".to_string()
)
);
ensure_ca_mode_for_hosts(dir.path(), &wanted).unwrap();
assert!(matches!(
inspect_leaf(dir.path(), &wanted),
LeafStatus::Reuse { .. }
));
assert_eq!(fs::read(ca_cert_path(dir.path())).unwrap(), ca_bytes);
}
#[test]
fn a_corrupt_leaf_key_is_reissued() {
let dir = tempfile::tempdir().unwrap();
let wanted = hosts(&["localhost"]);
ensure_ca_mode_for_hosts(dir.path(), &wanted).unwrap();
fs::write(
leaf_key_path(dir.path()),
"-----BEGIN PRIVATE KEY-----\ntrunc",
)
.unwrap();
assert_eq!(
inspect_leaf(dir.path(), &wanted),
LeafStatus::Reissue("the existing leaf key is unreadable".to_string())
);
ensure_ca_mode_for_hosts(dir.path(), &wanted).unwrap();
assert!(matches!(
inspect_leaf(dir.path(), &wanted),
LeafStatus::Reuse { .. }
));
}
#[test]
fn a_leaf_key_that_does_not_match_the_cert_is_reissued() {
let dir = tempfile::tempdir().unwrap();
let wanted = hosts(&["localhost"]);
ensure_ca_mode_for_hosts(dir.path(), &wanted).unwrap();
let other = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
fs::write(leaf_key_path(dir.path()), other.serialize_pem()).unwrap();
assert_eq!(
inspect_leaf(dir.path(), &wanted),
LeafStatus::Reissue("the existing leaf key does not match its cert".to_string())
);
ensure_ca_mode_for_hosts(dir.path(), &wanted).unwrap();
assert!(matches!(
inspect_leaf(dir.path(), &wanted),
LeafStatus::Reuse { .. }
));
}
#[test]
fn a_leaf_never_outlives_its_ca() {
let dir = tempfile::tempdir().unwrap();
let (mut ca, _) = ensure_ca(dir.path()).unwrap();
ca.not_after = OffsetDateTime::now_utc() + time::Duration::days(365 * 5);
issue_leaf(
dir.path(),
&ca,
&hosts(&["localhost"]),
time::Duration::days(LEAF_VALIDITY_DAYS),
)
.unwrap();
assert_eq!(
read_cert(&leaf_cert_path(dir.path()))
.not_after
.unix_timestamp(),
ca.not_after.unix_timestamp()
);
}
#[test]
fn reissuing_the_leaf_removes_the_old_sidecars() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("leaf.expiry"), "0").unwrap();
fs::write(dir.path().join("leaf.meta"), "0").unwrap();
ensure_ca_mode_for_hosts(dir.path(), &hosts(&["localhost"])).unwrap();
assert!(!dir.path().join("leaf.expiry").exists());
assert!(!dir.path().join("leaf.meta").exists());
}
}