v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,417 @@
|
||||
/// 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,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user