Files
RustProxy/src/tls/certificates.rs
T
2026-10-31 23:42:58 +01:00

397 lines
13 KiB
Rust

pub fn compile_admin(
settings: &Settings,
administration: &crate::settings::Administration,
directory: Option<&Path>,
) -> Result<Option<Arc<ServerConfig>>> {
if !administration.tls {
return Ok(None);
}
let directory = directory.context("administration TLS requires a certificate directory")?;
settings.policy.validate()?;
let key = if let Some(id) = administration.certificate_id.as_deref() {
let certificate = settings
.certificates
.iter()
.find(|certificate| certificate.id == id)
.context("administration certificate id is unknown")?;
Arc::new(load_pair(
directory,
&certificate.cert_file,
&certificate.key_file,
)?)
} else {
Arc::new(load_pair(directory, "default.pem", "default.key")?)
};
#[derive(Debug)]
struct Fixed(Arc<CertifiedKey>);
impl ResolvesServerCert for Fixed {
fn resolve(&self, _: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
Some(self.0.clone())
}
}
Ok(Some(server_config(
Arc::new(Fixed(key)),
&settings.policy,
true,
)?))
}
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let mut file = options.open(path)?;
file.write_all(bytes)?;
file.sync_all()?;
Ok(())
}
fn store_pair(directory: &Path, name: &str, cert: &[u8], key: &[u8]) -> Result<()> {
safe_name(name)?;
let cert_path = directory.join(format!("{name}.pem"));
let key_path = directory.join(format!("{name}.key"));
ensure!(
!cert_path.try_exists()? && !key_path.try_exists()?,
"certificate files already exist; use a new name for safe rotation"
);
parse_pair(cert, key)?;
write_new(&key_path, key)?;
if let Err(error) = write_new(&cert_path, cert) {
let _ = fs::remove_file(&key_path);
return Err(error);
}
#[cfg(unix)]
std::fs::File::open(directory)?.sync_all()?;
Ok(())
}
fn generate_locked(directory: &Path, name: &str, hosts: Vec<String>) -> Result<()> {
ensure!(!hosts.is_empty(), "provide at least one certificate SAN");
let hosts = hosts
.into_iter()
.map(|host| {
if host.parse::<std::net::IpAddr>().is_ok() {
Ok(host)
} else {
normalize_host_pattern(&host)
}
})
.collect::<Result<Vec<_>>>()?;
let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(hosts)?;
let key = Zeroizing::new(signing_key.serialize_pem());
store_pair(directory, name, cert.pem().as_bytes(), key.as_bytes())
}
pub fn initialize_default(directory: &Path) -> Result<()> {
crate::auth::prepare_directory(directory)?;
let _lock = crate::auth::lock_directory(directory)?;
let cert = directory.join("default.pem").try_exists()?;
let key = directory.join("default.key").try_exists()?;
ensure!(
cert == key,
"incomplete default certificate pair; restore both default.pem and default.key"
);
if !cert {
generate_locked(
directory,
"default",
vec![
"localhost".into(),
"demo.localhost".into(),
"127.0.0.1".into(),
"::1".into(),
],
)?;
}
load_pair(directory, "default.pem", "default.key")?;
Ok(())
}
pub fn generate(directory: &Path, name: &str, hosts: Vec<String>) -> Result<()> {
ensure!(
name != "default",
"the default certificate is managed at startup; use another name"
);
crate::auth::prepare_directory(directory)?;
let _lock = crate::auth::lock_directory(directory)?;
generate_locked(directory, name, hosts)
}
pub fn import(directory: &Path, name: &str, cert: PathBuf, key: PathBuf) -> Result<()> {
ensure!(
name != "default",
"use a named certificate and configure default_certificate instead"
);
crate::auth::prepare_directory(directory)?;
let _lock = crate::auth::lock_directory(directory)?;
store_pair(
directory,
name,
&read_pem(&cert, false)?,
&read_pem(&key, true)?,
)
}
#[derive(Debug, PartialEq, Eq)]
pub enum ExternalPairDeployment {
Unchanged {
covered_hosts: Vec<String>,
},
Staged {
cert_file: String,
key_file: String,
covered_hosts: Vec<String>,
},
}
fn external_generation_pair(certificate_id: &str, cert_file: &str, key_file: &str) -> bool {
let prefix = format!("external-{certificate_id}-");
let Some(cert_stem) = cert_file.strip_suffix(".pem") else {
return false;
};
let Some(key_stem) = key_file.strip_suffix(".key") else {
return false;
};
cert_stem == key_stem && cert_stem.starts_with(&prefix)
}
pub fn reconcile_external_pair(
directory: &Path,
certificate_id: &str,
current_pair: Option<(&str, &str)>,
certificate: &[u8],
key: &[u8],
) -> Result<ExternalPairDeployment> {
ensure!(
crate::config::valid_id(certificate_id),
"invalid certificate id"
);
ensure!(
certificate.len() <= MAX_PEM as usize && key.len() <= MAX_PEM as usize,
"each PEM input is limited to 128 KiB"
);
parse_pair(certificate, key)?;
let covered_hosts = certificate_dns_names(certificate)?;
crate::auth::prepare_directory(directory)?;
let _lock = crate::auth::lock_directory(directory)?;
if let Some((cert_file, key_file)) = current_pair {
if external_generation_pair(certificate_id, cert_file, key_file) {
let current_certificate = read_pem(&directory.join(cert_file), false);
let current_key = read_pem(&directory.join(key_file), true);
if let (Ok(current_certificate), Ok(current_key)) = (current_certificate, current_key) {
if current_certificate.as_slice() == certificate && current_key.as_slice() == key {
return Ok(ExternalPairDeployment::Unchanged { covered_hosts });
}
}
}
}
let name = format!(
"external-{}-{:032x}",
certificate_id,
rand::random::<u128>()
);
store_pair(directory, &name, certificate, key)?;
Ok(ExternalPairDeployment::Staged {
cert_file: format!("{name}.pem"),
key_file: format!("{name}.key"),
covered_hosts,
})
}
pub fn stage_external_pair(
directory: &Path,
certificate_id: &str,
certificate: &[u8],
key: &[u8],
) -> Result<(String, String, Vec<String>)> {
match reconcile_external_pair(directory, certificate_id, None, certificate, key)? {
ExternalPairDeployment::Staged {
cert_file,
key_file,
covered_hosts,
} => Ok((cert_file, key_file, covered_hosts)),
ExternalPairDeployment::Unchanged { .. } => {
unreachable!("reconcile without a current pair always stages")
}
}
}
pub fn remove_external_pair(directory: &Path, cert_file: &str, key_file: &str) -> Result<()> {
safe_name(cert_file)?;
safe_name(key_file)?;
ensure!(
cert_file.starts_with("external-")
&& cert_file.ends_with(".pem")
&& key_file.starts_with("external-")
&& key_file.ends_with(".key"),
"refusing to remove non-external certificate files"
);
for file in [cert_file, key_file] {
match fs::remove_file(directory.join(file)) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
#[cfg(unix)]
std::fs::File::open(directory)?.sync_all()?;
Ok(())
}
pub fn for_all_frontends(
settings: &Settings,
vhosts: &[crate::vhost::Vhost],
redirects: &[crate::redirect::Host],
not_found_hosts: &[crate::not_found::Host],
) -> Result<Settings> {
let mut result = settings.clone();
let certificate_ids: std::collections::HashSet<_> =
result.certificates.iter().map(|c| c.id.clone()).collect();
let mut assignments = std::collections::HashMap::<String, String>::new();
let mut assign = |hosts: &[String], id: &str, owner: &str| -> Result<()> {
ensure!(
certificate_ids.contains(id),
"{owner} references unknown certificate id"
);
for host in hosts {
if let Some(old) = assignments.insert(host.clone(), id.to_owned()) {
ensure!(
old == id,
"same hostname cannot select different TLS certificates"
);
}
}
Ok(())
};
for vhost in vhosts {
if let Some(id) = &vhost.certificate_id {
let hosts = vhost.routing_hosts()?;
assign(&hosts, id, "vhost")?;
if vhost.allow_certificate_mismatch {
for host in hosts {
result.forced_mismatches.insert((id.clone(), host));
}
}
}
}
for redirect in redirects {
if let Some(id) = &redirect.certificate_id {
assign(&redirect.frontend.hosts, id, "redirect")?;
}
}
for host in not_found_hosts {
if let Some(id) = &host.certificate_id {
assign(&host.frontend.hosts, id, "404 host")?;
}
}
for cert in &mut result.certificates {
cert.hosts.retain(|host| {
!assignments.contains_key(host.to_ascii_lowercase().trim_end_matches('.'))
});
for (host, id) in &assignments {
if id == &cert.id {
cert.hosts.push(host.clone());
}
}
cert.hosts.sort();
cert.hosts.dedup();
}
Ok(result)
}
pub fn for_frontends(
settings: &Settings,
vhosts: &[crate::vhost::Vhost],
redirects: &[crate::redirect::Host],
) -> Result<Settings> {
for_all_frontends(settings, vhosts, redirects, &[])
}
pub fn for_vhosts(settings: &Settings, vhosts: &[crate::vhost::Vhost]) -> Result<Settings> {
for_frontends(settings, vhosts, &[])
}
pub fn upload(
directory: &Path,
certificate: &[u8],
key: &[u8],
hosts: Vec<String>,
) -> Result<Certificate> {
ensure!(
certificate.len() <= MAX_PEM as usize && key.len() <= MAX_PEM as usize,
"each PEM input is limited to 128 KiB"
);
ensure!(!hosts.is_empty(), "provide at least one vhost name");
let certified = parse_pair(certificate, key)?;
let covered_hosts = certificate_dns_names(certificate)?;
let mut resolver = ResolvesServerCertUsingSni::new();
for host in &hosts {
let host = normalize_host_pattern(host)?;
let validation_name = host
.strip_prefix("*.")
.map(|base| format!("rustproxy-wildcard-check.{base}"))
.unwrap_or_else(|| host.clone());
resolver
.add(&validation_name, certified.clone())
.context("certificate does not cover vhost name")?;
}
crate::auth::prepare_directory(directory)?;
let _lock = crate::auth::lock_directory(directory)?;
let staged = fs::read_dir(directory)?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name().to_string_lossy().starts_with("upload-")
&& e.path().extension().is_some_and(|x| x == "key")
})
.count();
ensure!(
staged < 128,
"upload storage limit reached; remove unused upload-* pairs locally"
);
let id = format!("upload-{:032x}", rand::random::<u128>());
store_pair(directory, &id, certificate, key)?;
Ok(Certificate {
issuer: None,
cert_file: format!("{id}.pem"),
key_file: format!("{id}.key"),
id,
covered_hosts,
hosts: Vec::new(),
})
}
pub fn validate_acme(
directory: &Path,
id: String,
cert_file: String,
key_file: String,
domains: &[String],
) -> Result<Certificate> {
ensure!(crate::config::valid_id(&id), "invalid ACME certificate id");
ensure!(
cert_file.starts_with("acme-") && key_file.starts_with("acme-"),
"invalid ACME output name"
);
ensure!(!domains.is_empty(), "ACME requires at least one domain");
let key = load_pair(directory, &cert_file, &key_file)?;
let certificate_pem = read_pem(&directory.join(&cert_file), false)?;
let covered_hosts = certificate_dns_names(&certificate_pem)?;
let mut resolver = ResolvesServerCertUsingSni::new();
for domain in domains {
let domain = normalize_host_pattern(domain)?;
let name = domain
.strip_prefix("*.")
.map(|d| format!("rustproxy-acme-check.{d}"))
.unwrap_or_else(|| domain.clone());
resolver
.add(&normalize_host(&name)?, key.clone())
.context("ACME certificate does not cover requested domain")?;
}
Ok(Certificate {
issuer: None,
id,
covered_hosts,
hosts: Vec::new(),
cert_file,
key_file,
})
}