pub fn compile_admin( settings: &Settings, administration: &crate::settings::Administration, directory: Option<&Path>, ) -> Result>> { 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); impl ResolvesServerCert for Fixed { fn resolve(&self, _: ClientHello<'_>) -> Option> { 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) -> Result<()> { ensure!(!hosts.is_empty(), "provide at least one certificate SAN"); let hosts = hosts .into_iter() .map(|host| { if host.parse::().is_ok() { Ok(host) } else { normalize_host_pattern(&host) } }) .collect::>>()?; 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) -> 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, }, Staged { cert_file: String, key_file: String, covered_hosts: Vec, }, } 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 { 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::() ); 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)> { 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 { 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::::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 { for_all_frontends(settings, vhosts, redirects, &[]) } pub fn for_vhosts(settings: &Settings, vhosts: &[crate::vhost::Vhost]) -> Result { for_frontends(settings, vhosts, &[]) } pub fn upload( directory: &Path, certificate: &[u8], key: &[u8], hosts: Vec, ) -> Result { 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::()); 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 { 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, }) }