use std::{
collections::{BTreeSet, HashSet},
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use jiff::Timestamp;
use serde_json::{Value, json};
use tokio::{io::AsyncWriteExt, net::TcpStream};
use tracing::debug;
use x509_parser::prelude::*;
use super::SweepContext;
use crate::doctor::check::Check;
const NAME: &str = "caddy_certs";
const WARN_FRACTION: f64 = 21.0 / 90.0;
const FAIL_FRACTION: f64 = 7.0 / 90.0;
const RENEWAL_RATIO: f64 = 1.0 / 3.0;
const TLS_PORT: u16 = 443;
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Sev {
Warn,
Fail,
}
#[derive(Debug, PartialEq, Eq)]
enum Expiry {
Ok,
Warn,
Fail,
}
fn classify_expiry(remaining: i64, lifetime: i64) -> Expiry {
let renewal_window = lifetime as f64 * RENEWAL_RATIO;
if remaining as f64 >= renewal_window {
return Expiry::Ok;
}
let warn_at = (lifetime as f64 * WARN_FRACTION) as i64;
let fail_at = (lifetime as f64 * FAIL_FRACTION) as i64;
if remaining <= fail_at {
Expiry::Fail
} else if remaining <= warn_at {
Expiry::Warn
} else {
Expiry::Ok
}
}
pub async fn run(ctx: SweepContext) -> Check {
let Some(config) = fetch_admin_config(&ctx.http_client).await else {
return Check::skip(
NAME,
"caddy admin config unavailable",
"could not read the caddy admin API at localhost:2019",
);
};
let active = active_subjects(&config);
let mut certs: Vec<(String, DiskCert)> = Vec::new();
if let Some(dir) = certificates_dir() {
let mut files = Vec::new();
collect_crt_files(&dir, &mut files);
for path in files {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Some(cert) = parse_cert(&bytes) else {
debug!(path = %path.display(), "could not parse caddy cert");
continue;
};
if cert.covers_any(&active) {
certs.push((path.display().to_string(), cert));
} else {
debug!(path = %path.display(), "managed cert not in active config; skipping");
}
}
}
for (origin, pem) in manual_sources(&config) {
if let Some(cert) = parse_cert(&pem) {
certs.push((origin, cert));
}
}
if certs.is_empty() {
return Check::skip(
NAME,
"no active caddy certificates",
"the caddy config references no managed or manual certificates we could read",
);
}
let now = Timestamp::now().as_second();
let mut findings: Vec<(Sev, String)> = Vec::new();
let mut details: Vec<Value> = Vec::new();
let mut seen: HashSet<Vec<u8>> = HashSet::new();
for (origin, cert) in &certs {
if !seen.insert(cert.der.clone()) {
continue; }
let label = if cert.sans.is_empty() {
origin.clone()
} else {
cert.sans.join(", ")
};
let lifetime = (cert.not_after - cert.not_before).max(1);
let remaining = cert.not_after - now;
let days = remaining as f64 / 86400.0;
match classify_expiry(remaining, lifetime) {
Expiry::Fail => findings.push((
Sev::Fail,
format!("{label}: expires in {days:.1}d (renewal is failing)"),
)),
Expiry::Warn => findings.push((Sev::Warn, format!("{label}: expires in {days:.1}d"))),
Expiry::Ok => {}
}
let mut served_matches: Option<bool> = None;
if let Some(sni) = cert.sans.iter().find(|n| !n.contains('*')) {
match served_leaf(sni).await {
Ok(served_der) => {
let matches = served_der == cert.der;
served_matches = Some(matches);
if !matches {
findings.push((
Sev::Warn,
format!(
"{label}: served cert differs from configured cert (caddy needs a reload?)"
),
));
}
}
Err(e) => debug!(sni, error = %e, "could not read served cert"),
}
}
details.push(json!({
"names": cert.sans,
"origin": origin,
"not_after": cert.not_after,
"days_remaining": (days * 10.0).round() / 10.0,
"lifetime_days": lifetime / 86400,
"served_matches": served_matches,
}));
}
let worst = findings.iter().map(|(s, _)| *s).max();
let reasons = findings
.iter()
.map(|(_, m)| m.as_str())
.collect::<Vec<_>>()
.join("; ");
let n = details.len();
let check = match worst {
Some(Sev::Fail) => Check::fail(NAME, format!("{n} cert(s) checked"), reasons),
Some(Sev::Warn) => Check::warning(NAME, format!("{n} cert(s) checked"), reasons),
None => Check::pass(NAME, format!("{n} cert(s) valid")),
};
check.with_detail("certificates", Value::Array(details))
}
fn certificates_dir() -> Option<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
if let Some(dir) = std::env::var_os("BESTOOL_CADDY_DATA_DIR") {
roots.push(PathBuf::from(dir));
}
#[cfg(windows)]
{
roots.push(PathBuf::from(r"C:\Caddy"));
roots.push(PathBuf::from(r"C:\Caddy\data"));
if let Some(appdata) = std::env::var_os("APPDATA") {
roots.push(PathBuf::from(appdata).join("Caddy"));
}
}
#[cfg(not(windows))]
{
roots.push(PathBuf::from("/var/lib/caddy/.local/share/caddy"));
roots.push(PathBuf::from("/var/lib/caddy"));
}
roots.into_iter().find_map(|root| {
["certificates", "caddy/certificates"]
.into_iter()
.map(|sub| root.join(sub))
.find(|dir| dir.is_dir())
})
}
fn collect_crt_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_crt_files(&path, out);
} else if path.extension().is_some_and(|e| e == "crt") {
out.push(path);
}
}
}
struct DiskCert {
sans: Vec<String>,
not_before: i64,
not_after: i64,
der: Vec<u8>,
}
impl DiskCert {
fn covers_any(&self, active: &BTreeSet<String>) -> bool {
self.sans
.iter()
.any(|san| active.iter().any(|subj| name_matches(san, subj)))
}
}
fn parse_cert(pem: &[u8]) -> Option<DiskCert> {
let (_, pem) = parse_x509_pem(pem).ok()?;
let cert = pem.parse_x509().ok()?;
let sans = cert
.subject_alternative_name()
.ok()
.flatten()
.map(|san| {
san.value
.general_names
.iter()
.filter_map(|gn| match gn {
GeneralName::DNSName(d) => Some((*d).to_string()),
_ => None,
})
.collect()
})
.unwrap_or_default();
Some(DiskCert {
sans,
not_before: cert.validity().not_before.timestamp(),
not_after: cert.validity().not_after.timestamp(),
der: pem.contents,
})
}
async fn fetch_admin_config(client: &reqwest::Client) -> Option<serde_json::Value> {
let resp = client
.get("http://localhost:2019/config/")
.timeout(Duration::from_secs(3))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
resp.json().await.ok()
}
fn active_subjects(config: &serde_json::Value) -> BTreeSet<String> {
fn walk(value: &serde_json::Value, out: &mut BTreeSet<String>) {
match value {
serde_json::Value::Object(map) => {
for (key, val) in map {
if matches!(key.as_str(), "host" | "automate" | "subjects")
&& let Some(arr) = val.as_array()
{
out.extend(
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_ascii_lowercase()),
);
}
walk(val, out);
}
}
serde_json::Value::Array(arr) => arr.iter().for_each(|v| walk(v, out)),
_ => {}
}
}
let mut out = BTreeSet::new();
walk(config, &mut out);
out
}
fn manual_sources(config: &serde_json::Value) -> Vec<(String, Vec<u8>)> {
let mut out = Vec::new();
let certs = &config["apps"]["tls"]["certificates"];
if let Some(files) = certs["load_files"].as_array() {
for file in files {
if let Some(path) = file["certificate"].as_str()
&& let Ok(bytes) = std::fs::read(path)
{
out.push((path.to_string(), bytes));
}
}
}
if let Some(pems) = certs["load_pem"].as_array() {
for (i, entry) in pems.iter().enumerate() {
if let Some(pem) = entry["certificate"].as_str() {
out.push((
format!("caddy config load_pem[{i}]"),
pem.as_bytes().to_vec(),
));
}
}
}
out
}
fn name_matches(a: &str, b: &str) -> bool {
let a = a.trim_end_matches('.').to_ascii_lowercase();
let b = b.trim_end_matches('.').to_ascii_lowercase();
a == b || wildcard_covers(&a, &b) || wildcard_covers(&b, &a)
}
fn wildcard_covers(pattern: &str, name: &str) -> bool {
let Some(base) = pattern.strip_prefix("*.") else {
return false;
};
let Some(rest) = name.strip_suffix(base) else {
return false;
};
let label = rest.strip_suffix('.').unwrap_or_default();
!label.is_empty() && !label.contains('.')
}
async fn served_leaf(sni: &str) -> Result<Vec<u8>, String> {
use rustls::{
ClientConfig, DigitallySignedStruct, SignatureScheme,
client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
crypto::{CryptoProvider, verify_tls12_signature, verify_tls13_signature},
pki_types::{CertificateDer, ServerName, UnixTime},
};
use tokio_rustls::TlsConnector;
#[derive(Debug)]
struct AcceptAny(Arc<CryptoProvider>);
impl ServerCertVerifier for AcceptAny {
fn verify_server_cert(
&self,
_end_entity: &CertificateDer<'_>,
_intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp: &[u8],
_now: UnixTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls12_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls13_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let config = ClientConfig::builder_with_provider(provider.clone())
.with_safe_default_protocol_versions()
.map_err(|e| e.to_string())?
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAny(provider)))
.with_no_client_auth();
let connector = TlsConnector::from(Arc::new(config));
let server_name = ServerName::try_from(sni.to_string()).map_err(|e| e.to_string())?;
let handshake = async {
let tcp = TcpStream::connect(("127.0.0.1", TLS_PORT))
.await
.map_err(|e| e.to_string())?;
let mut tls = connector
.connect(server_name, tcp)
.await
.map_err(|e| e.to_string())?;
let der = tls
.get_ref()
.1
.peer_certificates()
.and_then(|c| c.first())
.map(|c| c.as_ref().to_vec())
.ok_or_else(|| "no peer certificate".to_string())?;
let _ = tls.shutdown().await;
Ok::<_, String>(der)
};
match tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake).await {
Ok(r) => r,
Err(_) => Err("handshake timed out".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn classify(remaining: i64, lifetime: i64) -> &'static str {
match classify_expiry(remaining, lifetime) {
Expiry::Ok => "pass",
Expiry::Warn => "warn",
Expiry::Fail => "fail",
}
}
const D: i64 = 86400;
#[test]
fn ninety_day_cert_bands() {
let life = 90 * D;
assert_eq!(classify(40 * D, life), "pass");
assert_eq!(classify(20 * D, life), "warn"); assert_eq!(classify(6 * D, life), "fail"); assert_eq!(classify(-1, life), "fail"); }
#[test]
fn short_lived_certs_scale() {
let life = 45 * D;
assert_eq!(classify(20 * D, life), "pass");
assert_eq!(classify(9 * D, life), "warn");
assert_eq!(classify(2 * D, life), "fail");
let life = 6 * D;
assert_eq!(classify(3 * D, life), "pass");
assert_eq!(classify(D, life), "warn");
assert_eq!(classify(D / 4, life), "fail");
}
#[test]
fn manually_issued_long_lived_certs() {
let life = 365 * D;
assert_eq!(classify(200 * D, life), "pass"); assert_eq!(classify(130 * D, life), "pass"); assert_eq!(classify(100 * D, life), "pass"); assert_eq!(classify(80 * D, life), "warn"); assert_eq!(classify(20 * D, life), "fail");
let life = 200 * D;
assert_eq!(classify(100 * D, life), "pass"); assert_eq!(classify(60 * D, life), "pass"); assert_eq!(classify(40 * D, life), "warn"); assert_eq!(classify(10 * D, life), "fail"); }
#[test]
fn no_alert_before_renewal_window() {
let life = 90 * D;
assert_eq!(classify(60 * D, life), "pass"); assert_eq!(classify(31 * D, life), "pass"); assert_eq!(classify_expiry(40 * D, life), Expiry::Ok);
}
#[test]
fn name_matching_handles_wildcards() {
assert!(name_matches("app.example.com", "app.example.com"));
assert!(name_matches("APP.example.com", "app.example.com")); assert!(name_matches("*.example.com", "app.example.com")); assert!(name_matches("app.example.com", "*.example.com")); assert!(!name_matches("*.example.com", "a.b.example.com")); assert!(!name_matches("*.example.com", "example.com")); assert!(!name_matches("app.example.com", "app.example.org"));
}
#[test]
fn active_subjects_pulls_hosts_automate_and_subjects() {
let config = serde_json::json!({
"apps": {
"http": { "servers": { "srv0": { "routes": [
{ "match": [{ "host": ["a.example.com", "b.example.com"] }],
"handle": [{ "handler": "subroute", "routes": [
{ "match": [{ "host": ["nested.example.com"] }] }
]}] }
]}}},
"tls": {
"certificates": { "automate": ["auto.example.com"] },
"automation": { "policies": [{ "subjects": ["policy.example.com"] }] }
}
}
});
let subjects = active_subjects(&config);
for host in [
"a.example.com",
"b.example.com",
"nested.example.com",
"auto.example.com",
"policy.example.com",
] {
assert!(subjects.contains(host), "missing {host}");
}
}
#[test]
fn manual_sources_reads_inline_pem() {
let config = serde_json::json!({
"apps": { "tls": { "certificates": { "load_pem": [
{ "certificate": "-----BEGIN CERTIFICATE-----\nINLINE\n-----END CERTIFICATE-----", "key": "..." }
]}}}
});
let sources = manual_sources(&config);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].0, "caddy config load_pem[0]");
assert!(sources[0].1.starts_with(b"-----BEGIN CERTIFICATE-----"));
}
}