418 lines
15 KiB
Rust
418 lines
15 KiB
Rust
/// Build the fixed administration TLS configuration. A named inventory
|
|
/// certificate may be selected; otherwise the app-managed default pair is used.
|
|
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, key_pair } = rcgen::generate_simple_self_signed(hosts)?;
|
|
let key = Zeroizing::new(key_pair.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)?,
|
|
)
|
|
}
|
|
|
|
/// Result of reconciling an externally supplied certificate/key pair with the
|
|
/// immutable generation currently selected by a manual certificate id.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum ExternalPairDeployment {
|
|
/// The configured RustProxy-owned generation already contains the exact
|
|
/// certificate chain and private key supplied by the external issuer.
|
|
Unchanged { covered_hosts: Vec<String> },
|
|
/// A new immutable generation was written and must be published through the
|
|
/// revisioned configuration path before the old generation can be removed.
|
|
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)
|
|
}
|
|
|
|
/// Validate an externally supplied pair and reconcile it with the currently
|
|
/// selected RustProxy-owned immutable generation. Repeating the same deploy is
|
|
/// therefore a no-op, while a missing/different pair stages a fresh generation.
|
|
///
|
|
/// `current_pair` is advisory: unreadable/missing current files are treated as
|
|
/// drift and repaired by staging a new generation instead of failing the deploy.
|
|
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,
|
|
})
|
|
}
|
|
|
|
/// Stage a locally supplied certificate/key pair under generation-specific
|
|
/// filenames. This compatibility wrapper always stages a new generation.
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Best-effort rollback for a generation created by `stage_external_pair`.
|
|
/// Only generation-specific external files may be removed through this helper.
|
|
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(())
|
|
}
|
|
|
|
/// Select named inventory certificates per vhost/redirect host. A single global
|
|
/// SNI resolver cannot give the same name different certificates by destination IP.
|
|
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 {
|
|
assign(&vhost.frontend.hosts, id, "vhost")?;
|
|
}
|
|
}
|
|
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('.').to_string())
|
|
});
|
|
for (host, id) in &assignments {
|
|
if id == &cert.id {
|
|
cert.hosts.push(host.clone());
|
|
}
|
|
}
|
|
cert.hosts.sort();
|
|
cert.hosts.dedup();
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
/// Compatibility helper for callers without dedicated local-response hosts.
|
|
pub fn for_frontends(
|
|
settings: &Settings,
|
|
vhosts: &[crate::vhost::Vhost],
|
|
redirects: &[crate::redirect::Host],
|
|
) -> Result<Settings> {
|
|
for_all_frontends(settings, vhosts, redirects, &[])
|
|
}
|
|
|
|
/// Compatibility helper used by focused TLS tests and callers that only have vhosts.
|
|
pub fn for_vhosts(settings: &Settings, vhosts: &[crate::vhost::Vhost]) -> Result<Settings> {
|
|
for_frontends(settings, vhosts, &[])
|
|
}
|
|
/// Upload staging: files are private and immutable; configuration publication is
|
|
/// a separate revisioned operation. Unbound files never alter active TLS state.
|
|
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)?;
|
|
// Bound abandoned staging files. Admins may remove unreferenced upload-* pairs
|
|
// locally; never delete potentially active files automatically.
|
|
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(),
|
|
})
|
|
}
|
|
/// Validate immutable ACME outputs before they may be published to the library.
|
|
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)?;
|
|
// A concrete name checks a wildcard SAN without relying on wildcard
|
|
// behavior in rustls' stock validation resolver.
|
|
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,
|
|
})
|
|
}
|