use std::io::Write;
use std::net::IpAddr;
use std::path::Path;
use anyhow::{Context, Result};
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, SanType};
use tracing::{info, warn};
pub const DEFAULT_HOSTS: [&str; 3] = ["localhost", "127.0.0.1", "::1"];
pub struct SelfSigned {
pub cert_pem: String,
pub key_pem: String,
}
pub fn generate(hosts: &[String], days: u32) -> Result<SelfSigned> {
anyhow::ensure!(!hosts.is_empty(), "no hosts to put in the certificate");
anyhow::ensure!(days > 0, "certificate validity must be at least one day");
let mut params = CertificateParams::default();
params.subject_alt_names =
hosts
.iter()
.map(|h| match h.parse::<IpAddr>() {
Ok(ip) => Ok(SanType::IpAddress(ip)),
Err(_) => h.clone().try_into().map(SanType::DnsName).with_context(|| {
format!("{h:?} is neither an IP address nor an ASCII hostname")
}),
})
.collect::<Result<Vec<_>>>()?;
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, hosts[0].clone());
dn.push(DnType::OrganizationName, "EdgeGuard self-signed");
params.distinguished_name = dn;
let now = time::OffsetDateTime::now_utc();
params.not_before = now - time::Duration::hours(1);
params.not_after = now
.checked_add(time::Duration::days(i64::from(days)))
.context("certificate validity exceeds the supported date range")?;
let key = KeyPair::generate().context("generating the certificate key pair")?;
let cert = params
.self_signed(&key)
.context("self-signing the certificate")?;
Ok(SelfSigned {
cert_pem: cert.pem(),
key_pem: key.serialize_pem(),
})
}
pub fn path_present(path: &str) -> bool {
!matches!(
std::fs::symlink_metadata(path),
Err(e) if e.kind() == std::io::ErrorKind::NotFound
)
}
pub fn write_to(
hosts: &[String],
days: u32,
cert_path: &str,
key_path: &str,
) -> Result<SelfSigned> {
anyhow::ensure!(
cert_path != key_path,
"tls.cert_path and tls.key_path must be different files (both are {cert_path:?}); \
the key would overwrite the certificate"
);
let generated = generate(hosts, days)?;
for path in [cert_path, key_path] {
if let Some(parent) = Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating directory {}", parent.display()))?;
}
}
}
let cert_tmp = stage(cert_path, &generated.cert_pem, false)
.with_context(|| format!("staging certificate for {cert_path}"))?;
let key_tmp = match stage(key_path, &generated.key_pem, true) {
Ok(tmp) => tmp,
Err(e) => {
let _ = std::fs::remove_file(&cert_tmp);
return Err(e).with_context(|| format!("staging private key for {key_path}"));
}
};
let backup = backup_path(cert_path);
let had_cert = match std::fs::rename(cert_path, &backup) {
Ok(()) => true,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
Err(e) => {
let _ = std::fs::remove_file(&cert_tmp);
let _ = std::fs::remove_file(&key_tmp);
return Err(e).with_context(|| format!("setting aside the existing {cert_path}"));
}
};
let published = publish(&cert_tmp, cert_path).and_then(|()| publish(&key_tmp, key_path));
match published {
Ok(()) => {
if had_cert {
let _ = std::fs::remove_file(&backup);
}
}
Err(e) => {
let _ = std::fs::remove_file(cert_path);
if had_cert {
let _ = std::fs::rename(&backup, cert_path);
}
let _ = std::fs::remove_file(&cert_tmp);
let _ = std::fs::remove_file(&key_tmp);
return Err(e).with_context(|| {
format!("publishing the certificate pair ({cert_path} and {key_path})")
});
}
}
info!(
cert = %cert_path,
key = %key_path,
hosts = %hosts.join(", "),
days,
"generated a self-signed certificate (clients must trust it explicitly; \
use [tls.acme] for a publicly trusted one)"
);
Ok(generated)
}
fn stage(path: &str, contents: &str, private: bool) -> Result<String> {
let tmp = format!("{path}.tmp.{}", std::process::id());
if Path::new(&tmp).exists() {
let _ = std::fs::remove_file(&tmp);
}
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
if private {
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let result = (|| -> Result<()> {
let mut file = opts.open(&tmp)?;
#[cfg(unix)]
if private {
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
file.write_all(contents.as_bytes())?;
file.sync_all()?;
Ok(())
})();
match result {
Ok(()) => Ok(tmp),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
fn backup_path(path: &str) -> String {
format!("{path}.bak.{}", std::process::id())
}
fn publish(tmp: &str, path: &str) -> Result<()> {
std::fs::rename(tmp, path)?;
Ok(())
}
pub fn ensure(hosts: &[String], days: u32, cert_path: &str, key_path: &str) -> Result<bool> {
anyhow::ensure!(
!cert_path.is_empty() && !key_path.is_empty(),
"tls.self_signed needs tls.cert_path and tls.key_path set — they are where the \
generated certificate is written"
);
let have_cert = path_present(cert_path);
let have_key = path_present(key_path);
if have_cert && have_key {
info!(cert = %cert_path, "self-signed: reusing the existing certificate");
return Ok(false);
}
if have_cert != have_key {
warn!(
missing = if have_cert { key_path } else { cert_path },
"self-signed: only half of the certificate pair is present; regenerating both"
);
}
write_to(hosts, days, cert_path, key_path)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
fn hosts(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn generates_a_loadable_certificate_and_key() {
let dir = std::env::temp_dir().join(format!("eg-selfsigned-{}", std::process::id()));
let cert = dir.join("cert.pem");
let key = dir.join("key.pem");
let cert_s = cert.to_str().unwrap();
let key_s = key.to_str().unwrap();
let generated = write_to(&hosts(&DEFAULT_HOSTS), 30, cert_s, key_s);
assert!(
generated.is_ok(),
"generation failed: {:?}",
generated.err()
);
crate::tls::init_crypto();
assert!(
crate::tls::load_server_config(cert_s, key_s).is_ok(),
"rustls rejected the generated certificate/key pair"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn key_file_is_not_world_readable() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("eg-perm-{}", std::process::id()));
let cert = dir.join("cert.pem");
let key = dir.join("key.pem");
write_to(
&hosts(&["localhost"]),
1,
cert.to_str().unwrap(),
key.to_str().unwrap(),
)
.unwrap();
let mode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "private key mode was {mode:o}, expected 600");
let _ = std::fs::remove_dir_all(&dir);
}
}
#[test]
fn ensure_is_idempotent() {
let dir = std::env::temp_dir().join(format!("eg-ensure-{}", std::process::id()));
let cert = dir.join("cert.pem");
let key = dir.join("key.pem");
let (c, k) = (cert.to_str().unwrap(), key.to_str().unwrap());
assert!(
ensure(&hosts(&["localhost"]), 1, c, k).unwrap(),
"first call should generate"
);
let first = std::fs::read_to_string(&cert).unwrap();
assert!(
!ensure(&hosts(&["localhost"]), 1, c, k).unwrap(),
"second call should reuse"
);
assert_eq!(
first,
std::fs::read_to_string(&cert).unwrap(),
"cert was regenerated"
);
std::fs::remove_file(&key).unwrap();
assert!(
ensure(&hosts(&["localhost"]), 1, c, k).unwrap(),
"half a pair should regenerate"
);
assert_ne!(first, std::fs::read_to_string(&cert).unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rejects_empty_hosts_and_zero_validity() {
assert!(generate(&[], 30).is_err());
assert!(generate(&hosts(&["localhost"]), 0).is_err());
}
#[test]
fn an_unrepresentable_validity_errors_rather_than_panicking() {
assert!(generate(&hosts(&["localhost"]), u32::MAX).is_err());
assert!(generate(&hosts(&["localhost"]), 365).is_ok());
}
#[test]
fn rejects_the_same_path_for_certificate_and_key() {
let dir = std::env::temp_dir().join(format!("eg-samepath-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let both = dir.join("pair.pem");
let both = both.to_str().unwrap();
assert!(write_to(&hosts(&["localhost"]), 1, both, both).is_err());
assert!(
!Path::new(both).exists(),
"nothing should have been written"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn regenerating_over_an_existing_world_readable_key_tightens_it() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("eg-remode-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cert = dir.join("cert.pem");
let key = dir.join("key.pem");
let (c, k) = (cert.to_str().unwrap(), key.to_str().unwrap());
std::fs::write(&key, "stale").unwrap();
std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(ensure(&hosts(&["localhost"]), 1, c, k).unwrap());
let mode = std::fs::metadata(&key).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"regenerated key mode was {mode:o}, expected 600"
);
assert_ne!(std::fs::read_to_string(&key).unwrap(), "stale");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_dangling_symlink_counts_as_present() {
let dir = std::env::temp_dir().join(format!("eg-symlink-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let link = dir.join("cert.pem");
std::os::unix::fs::symlink(dir.join("nowhere.pem"), &link).unwrap();
assert!(
!Path::new(&link).exists(),
"precondition: exists() is fooled by this"
);
assert!(
path_present(link.to_str().unwrap()),
"the link itself is there"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_key_write_leaves_the_existing_pair_untouched() {
let dir = std::env::temp_dir().join(format!("eg-keyfail-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cert = dir.join("cert.pem");
std::fs::write(&cert, "OLD CERT").unwrap();
let key = dir.join("key.pem");
std::fs::create_dir_all(&key).unwrap();
let before = std::fs::read_to_string(&cert).unwrap();
let r = write_to(
&hosts(&["localhost"]),
1,
cert.to_str().unwrap(),
key.to_str().unwrap(),
);
assert!(r.is_err(), "writing over a directory should fail");
assert_eq!(
std::fs::read_to_string(&cert).unwrap(),
before,
"the live certificate was replaced despite the key write failing"
);
let strays: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp."))
.collect();
assert!(strays.is_empty(), "staging files left behind: {strays:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn leaves_no_temporary_files_behind() {
let dir = std::env::temp_dir().join(format!("eg-tmp-{}", std::process::id()));
let cert = dir.join("cert.pem");
let key = dir.join("key.pem");
write_to(
&hosts(&["localhost"]),
1,
cert.to_str().unwrap(),
key.to_str().unwrap(),
)
.unwrap();
let strays: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.contains(".tmp."))
.collect();
assert!(strays.is_empty(), "staging files left behind: {strays:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ensure_requires_paths() {
assert!(ensure(&hosts(&["localhost"]), 1, "", "").is_err());
}
#[test]
fn ip_hosts_become_ip_sans_not_dns_names() {
let generated = generate(&hosts(&["localhost", "127.0.0.1"]), 1).unwrap();
assert!(generated
.cert_pem
.starts_with("-----BEGIN CERTIFICATE-----"));
let mut reader = std::io::BufReader::new(generated.cert_pem.as_bytes());
let certs: Vec<_> = rustls_pemfile::certs(&mut reader)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(certs.len(), 1);
}
}