use std::fmt;
use std::fs::{self, DirBuilder, OpenOptions};
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use rcgen::{
BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair,
KeyUsagePurpose,
};
use time::{Duration, OffsetDateTime};
use crate::hostname::normalize_public_hostname;
use crate::tls_material::{SERVER_CERT_FILENAME, SERVER_KEY_FILENAME};
pub const SERVER_CA_FILENAME: &str = "server-ca.crt";
pub const SERVER_CA_LIFETIME_DAYS: u64 = 3650;
pub const SERVER_CERT_LIFETIME_DAYS: u64 = 90;
const SERVER_STATE_DIR: &str = "state";
const SERVER_CA_KEY_FILENAME: &str = "server-ca.key";
const SERVER_HOSTNAME_FILENAME: &str = "server-hostname.txt";
pub struct ManualServerCertificateState {
pub hostname: String,
}
#[derive(Debug)]
pub enum ServerCertError {
CreateDirectory { path: PathBuf, source: io::Error },
ReadFile { path: PathBuf, source: io::Error },
WriteFile { path: PathBuf, source: io::Error },
Generate(rcgen::Error),
ParseState(rcgen::Error),
}
impl fmt::Display for ServerCertError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CreateDirectory { path, .. } => {
write!(formatter, "failed to create {}", path.display())
}
Self::ReadFile { path, .. } => {
write!(formatter, "failed to read {}", path.display())
}
Self::WriteFile { path, .. } => {
write!(formatter, "failed to write {}", path.display())
}
Self::Generate(_) => formatter.write_str("failed to generate server certificates"),
Self::ParseState(_) => {
formatter.write_str("failed to parse stored server certificate state")
}
}
}
}
impl std::error::Error for ServerCertError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::CreateDirectory { source, .. }
| Self::ReadFile { source, .. }
| Self::WriteFile { source, .. } => Some(source),
Self::Generate(source) | Self::ParseState(source) => Some(source),
}
}
}
impl From<rcgen::Error> for ServerCertError {
fn from(source: rcgen::Error) -> Self {
Self::Generate(source)
}
}
pub fn initialize_manual_server_certificate(
directory: &Path,
hostname: &str,
) -> Result<(), ServerCertError> {
let hostname = normalize_public_hostname(hostname);
let manual_state_directory = manual_state_directory(directory);
create_directory(directory, 0o755)?;
create_directory(&directory.join(SERVER_STATE_DIR), 0o700)?;
create_directory(&manual_state_directory, 0o700)?;
let generated = generate_manual_server_cert_material(&hostname)?;
write_new_file_with_mode(
&directory.join(SERVER_CERT_FILENAME),
generated.server_cert_pem.as_bytes(),
0o644,
)?;
write_new_file_with_mode(
&directory.join(SERVER_KEY_FILENAME),
generated.server_key_pem.as_bytes(),
0o600,
)?;
write_new_file_with_mode(
&directory.join(SERVER_CA_FILENAME),
generated.server_ca_pem.as_bytes(),
0o644,
)?;
write_new_file_with_mode(
&manual_state_directory.join(SERVER_CA_KEY_FILENAME),
generated.server_ca_key_pem.as_bytes(),
0o600,
)?;
write_new_file_with_mode(
&manual_state_directory.join(SERVER_HOSTNAME_FILENAME),
hostname.as_bytes(),
0o644,
)?;
Ok(())
}
pub fn inspect_manual_server_certificate(
directory: &Path,
) -> Result<ManualServerCertificateState, ServerCertError> {
let cert_path = directory.join(SERVER_CERT_FILENAME);
let key_path = directory.join(SERVER_KEY_FILENAME);
let ca_path = directory.join(SERVER_CA_FILENAME);
let ca_key_path = manual_state_directory(directory).join(SERVER_CA_KEY_FILENAME);
let _ = fs::read_to_string(&cert_path).map_err(|source| ServerCertError::ReadFile {
path: cert_path,
source,
})?;
let _ = fs::read_to_string(&key_path).map_err(|source| ServerCertError::ReadFile {
path: key_path,
source,
})?;
let _ = fs::read_to_string(&ca_path).map_err(|source| ServerCertError::ReadFile {
path: ca_path,
source,
})?;
let _ = fs::read_to_string(&ca_key_path).map_err(|source| ServerCertError::ReadFile {
path: ca_key_path,
source,
})?;
let _ = load_manual_server_ca_issuer(directory)?;
Ok(ManualServerCertificateState {
hostname: load_stored_hostname(directory)?,
})
}
pub fn renew_manual_server_certificate(directory: &Path) -> Result<(), ServerCertError> {
let hostname = load_stored_hostname(directory)?;
let issuer = load_manual_server_ca_issuer(directory)?;
let generated = generate_server_leaf_cert_material(&hostname, &issuer)?;
replace_file_atomically_with_mode(
&directory.join(SERVER_CERT_FILENAME),
generated.server_cert_pem.as_bytes(),
0o644,
)?;
replace_file_atomically_with_mode(
&directory.join(SERVER_KEY_FILENAME),
generated.server_key_pem.as_bytes(),
0o600,
)?;
Ok(())
}
pub fn rotate_manual_server_certificate_authority(
directory: &Path,
hostname: &str,
) -> Result<(), ServerCertError> {
let hostname = normalize_public_hostname(hostname);
let manual_state_directory = manual_state_directory(directory);
let generated = generate_manual_server_cert_material(&hostname)?;
replace_file_atomically_with_mode(
&directory.join(SERVER_CERT_FILENAME),
generated.server_cert_pem.as_bytes(),
0o644,
)?;
replace_file_atomically_with_mode(
&directory.join(SERVER_KEY_FILENAME),
generated.server_key_pem.as_bytes(),
0o600,
)?;
replace_file_atomically_with_mode(
&directory.join(SERVER_CA_FILENAME),
generated.server_ca_pem.as_bytes(),
0o644,
)?;
replace_file_atomically_with_mode(
&manual_state_directory.join(SERVER_CA_KEY_FILENAME),
generated.server_ca_key_pem.as_bytes(),
0o600,
)?;
replace_file_atomically_with_mode(
&manual_state_directory.join(SERVER_HOSTNAME_FILENAME),
hostname.as_bytes(),
0o644,
)?;
Ok(())
}
struct GeneratedServerCertMaterial {
server_cert_pem: String,
server_key_pem: String,
server_ca_pem: String,
server_ca_key_pem: String,
}
fn generate_manual_server_cert_material(
hostname: &str,
) -> Result<GeneratedServerCertMaterial, rcgen::Error> {
let not_before = OffsetDateTime::now_utc() - Duration::minutes(1);
let ca_params = server_ca_params(not_before)?;
let ca_key = KeyPair::generate()?;
let ca_key_pem = ca_key.serialize_pem();
let ca_cert = ca_params.self_signed(&ca_key)?;
let issuer = Issuer::new(ca_params, ca_key);
let leaf_material = generate_server_leaf_cert_material(hostname, &issuer)?;
Ok(GeneratedServerCertMaterial {
server_cert_pem: leaf_material.server_cert_pem,
server_key_pem: leaf_material.server_key_pem,
server_ca_pem: ca_cert.pem(),
server_ca_key_pem: ca_key_pem,
})
}
struct GeneratedServerLeafCertMaterial {
server_cert_pem: String,
server_key_pem: String,
}
fn generate_server_leaf_cert_material(
hostname: &str,
issuer: &Issuer<'_, KeyPair>,
) -> Result<GeneratedServerLeafCertMaterial, rcgen::Error> {
let leaf_params =
server_leaf_params(hostname, OffsetDateTime::now_utc() - Duration::minutes(1))?;
let leaf_key = KeyPair::generate()?;
let leaf_key_pem = leaf_key.serialize_pem();
let leaf_cert = leaf_params.signed_by(&leaf_key, issuer)?;
Ok(GeneratedServerLeafCertMaterial {
server_cert_pem: leaf_cert.pem(),
server_key_pem: leaf_key_pem,
})
}
fn server_ca_params(not_before: OffsetDateTime) -> Result<CertificateParams, rcgen::Error> {
let mut ca_params = CertificateParams::new(Vec::new())?;
ca_params.not_before = not_before;
ca_params.not_after = not_before + Duration::days(SERVER_CA_LIFETIME_DAYS as i64);
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params
.distinguished_name
.push(DnType::CommonName, "Runewarp Server CA");
ca_params.key_usages.push(KeyUsagePurpose::DigitalSignature);
ca_params.key_usages.push(KeyUsagePurpose::KeyCertSign);
ca_params.key_usages.push(KeyUsagePurpose::CrlSign);
Ok(ca_params)
}
fn server_leaf_params(
hostname: &str,
not_before: OffsetDateTime,
) -> Result<CertificateParams, rcgen::Error> {
let mut leaf_params = CertificateParams::new(vec![hostname.to_owned()])?;
leaf_params.not_before = not_before;
leaf_params.not_after = not_before + Duration::days(SERVER_CERT_LIFETIME_DAYS as i64);
leaf_params
.distinguished_name
.push(DnType::CommonName, hostname.to_owned());
leaf_params.use_authority_key_identifier_extension = true;
leaf_params
.key_usages
.push(KeyUsagePurpose::DigitalSignature);
leaf_params
.key_usages
.push(KeyUsagePurpose::KeyEncipherment);
leaf_params
.extended_key_usages
.push(ExtendedKeyUsagePurpose::ServerAuth);
Ok(leaf_params)
}
fn manual_state_directory(directory: &Path) -> PathBuf {
directory.join(SERVER_STATE_DIR)
}
fn load_stored_hostname(directory: &Path) -> Result<String, ServerCertError> {
let path = manual_state_directory(directory).join(SERVER_HOSTNAME_FILENAME);
let hostname = fs::read_to_string(&path).map_err(|source| ServerCertError::ReadFile {
path: path.clone(),
source,
})?;
Ok(normalize_public_hostname(hostname.trim()))
}
fn load_manual_server_ca_issuer(
directory: &Path,
) -> Result<Issuer<'static, KeyPair>, ServerCertError> {
let path = manual_state_directory(directory).join(SERVER_CA_KEY_FILENAME);
let server_ca_key_pem =
fs::read_to_string(&path).map_err(|source| ServerCertError::ReadFile {
path: path.clone(),
source,
})?;
let ca_key = KeyPair::from_pem(&server_ca_key_pem).map_err(ServerCertError::ParseState)?;
Ok(Issuer::new(
server_ca_params(OffsetDateTime::now_utc() - Duration::minutes(1))
.map_err(ServerCertError::ParseState)?,
ca_key,
))
}
fn create_directory(path: &Path, mode: u32) -> Result<(), ServerCertError> {
let mut builder = DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
builder.mode(mode);
builder
.create(path)
.map_err(|source| ServerCertError::CreateDirectory {
path: path.to_path_buf(),
source,
})
}
fn write_new_file_with_mode(
path: &Path,
contents: &[u8],
mode: u32,
) -> Result<(), ServerCertError> {
let mut file = open_new_file_with_mode(path, mode)?;
file.write_all(contents)
.map_err(|source| ServerCertError::WriteFile {
path: path.to_path_buf(),
source,
})?;
Ok(())
}
fn replace_file_atomically_with_mode(
path: &Path,
contents: &[u8],
mode: u32,
) -> Result<(), ServerCertError> {
let Some(parent) = path.parent() else {
return Err(ServerCertError::WriteFile {
path: path.to_path_buf(),
source: io::Error::new(ErrorKind::InvalidInput, "missing parent directory"),
});
};
let Some(filename) = path.file_name() else {
return Err(ServerCertError::WriteFile {
path: path.to_path_buf(),
source: io::Error::new(ErrorKind::InvalidInput, "missing filename"),
});
};
for attempt in 0..16 {
let temporary_path = parent.join(format!(
".{}.runewarp-tmp-{}-{attempt}",
filename.to_string_lossy(),
std::process::id()
));
let mut file = match open_new_file_with_mode(&temporary_path, mode) {
Ok(file) => file,
Err(ServerCertError::WriteFile { source, .. })
if source.kind() == ErrorKind::AlreadyExists =>
{
continue;
}
Err(error) => return Err(error),
};
if let Err(source) = file.write_all(contents) {
let _ = fs::remove_file(&temporary_path);
return Err(ServerCertError::WriteFile {
path: path.to_path_buf(),
source,
});
}
drop(file);
if let Err(source) = fs::rename(&temporary_path, path) {
let _ = fs::remove_file(&temporary_path);
return Err(ServerCertError::WriteFile {
path: path.to_path_buf(),
source,
});
}
return Ok(());
}
Err(ServerCertError::WriteFile {
path: path.to_path_buf(),
source: io::Error::new(
ErrorKind::AlreadyExists,
"failed to allocate a temporary file for atomic replacement",
),
})
}
fn open_new_file_with_mode(path: &Path, mode: u32) -> Result<std::fs::File, ServerCertError> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(mode);
options
.open(path)
.map_err(|source| ServerCertError::WriteFile {
path: path.to_path_buf(),
source,
})
}