This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 11:15:58 +02:00
1 parent 87e571c170
commit fa7abc9e53
308 files changed
+49746 -50237

No files matched your search

+417
View File
@@ -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,
})
}
+418
View File
@@ -0,0 +1,418 @@
/// Extract the DNS SubjectAltName set from the leaf certificate. This parser is
/// deliberately narrow: it walks only the DER structures required to locate the
/// SAN extension and ignores non-DNS GeneralName variants. Certificate validity
/// and key matching are still delegated to rustls.
fn certificate_dns_names(certificate: &[u8]) -> Result<Vec<String>> {
let mut reader = certificate;
let certs = rustls_pemfile::certs(&mut reader).collect::<io::Result<Vec<_>>>()?;
let leaf = certs.first().context("certificate chain is empty")?;
let der = leaf.as_ref();
let mut root_offset = 0;
let (tag, certificate_sequence) = der_tlv(der, &mut root_offset)?;
ensure!(
tag == 0x30 && root_offset == der.len(),
"invalid X.509 certificate DER"
);
let mut certificate_offset = 0;
let (tag, tbs) = der_tlv(certificate_sequence, &mut certificate_offset)?;
ensure!(tag == 0x30, "invalid X.509 TBSCertificate");
let mut tbs_offset = 0;
if tbs.first() == Some(&0xa0) {
let _ = der_tlv(tbs, &mut tbs_offset)?;
}
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo
for _ in 0..6 {
let _ = der_tlv(tbs, &mut tbs_offset)?;
}
let mut names = Vec::new();
while tbs_offset < tbs.len() {
let (tag, value) = der_tlv(tbs, &mut tbs_offset)?;
if tag != 0xa3 {
continue;
}
let mut wrapper_offset = 0;
let (tag, extensions) = der_tlv(value, &mut wrapper_offset)?;
ensure!(tag == 0x30, "invalid X.509 extensions");
let mut extensions_offset = 0;
while extensions_offset < extensions.len() {
let (tag, extension) = der_tlv(extensions, &mut extensions_offset)?;
ensure!(tag == 0x30, "invalid X.509 extension");
let mut extension_offset = 0;
let (oid_tag, oid) = der_tlv(extension, &mut extension_offset)?;
ensure!(oid_tag == 0x06, "invalid X.509 extension OID");
if extension_offset < extension.len() && extension[extension_offset] == 0x01 {
let _ = der_tlv(extension, &mut extension_offset)?;
}
let (value_tag, encoded_value) = der_tlv(extension, &mut extension_offset)?;
ensure!(value_tag == 0x04, "invalid X.509 extension value");
if oid != [0x55, 0x1d, 0x11] {
continue;
}
let mut san_offset = 0;
let (san_tag, general_names) = der_tlv(encoded_value, &mut san_offset)?;
ensure!(san_tag == 0x30, "invalid SubjectAltName extension");
let mut names_offset = 0;
while names_offset < general_names.len() {
let (name_tag, raw) = der_tlv(general_names, &mut names_offset)?;
if name_tag != 0x82 {
continue;
}
let Ok(name) = std::str::from_utf8(raw) else {
continue;
};
if let Ok(name) = normalize_host_pattern(name) {
names.push(name);
}
}
}
break;
}
names.sort();
names.dedup();
Ok(names)
}
#[derive(Debug, Clone, Serialize)]
pub struct CertificateMetadata {
pub covered_hosts: Vec<String>,
pub not_before: Option<String>,
pub not_after: Option<String>,
pub not_before_unix: Option<i64>,
pub not_after_unix: Option<i64>,
pub days_remaining: Option<i64>,
pub issuer: Option<String>,
pub expiry_status: &'static str,
}
fn der_text(tag: u8, raw: &[u8]) -> Option<String> {
let value = match tag {
0x0c | 0x13 | 0x14 | 0x16 => std::str::from_utf8(raw).ok()?.to_owned(),
0x1e if raw.len() % 2 == 0 => {
let units: Vec<u16> = raw
.chunks_exact(2)
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
.collect();
String::from_utf16(&units).ok()?
}
_ => return None,
};
let value: String = value
.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.take(256)
.collect();
(!value.trim().is_empty()).then(|| value.trim().to_owned())
}
fn certificate_issuer_name(encoded: &[u8]) -> Option<String> {
let mut offset = 0;
let mut parts = Vec::new();
while offset < encoded.len() && parts.len() < 16 {
let (set_tag, set_value) = der_tlv(encoded, &mut offset).ok()?;
if set_tag != 0x31 {
continue;
}
let mut set_offset = 0;
while set_offset < set_value.len() && parts.len() < 16 {
let (sequence_tag, sequence) = der_tlv(set_value, &mut set_offset).ok()?;
if sequence_tag != 0x30 {
continue;
}
let mut sequence_offset = 0;
let (oid_tag, oid) = der_tlv(sequence, &mut sequence_offset).ok()?;
if oid_tag != 0x06 || sequence_offset >= sequence.len() {
continue;
}
let (value_tag, value) = der_tlv(sequence, &mut sequence_offset).ok()?;
let Some(value) = der_text(value_tag, value) else {
continue;
};
let label = if oid == [0x55, 0x04, 0x03] {
"CN"
} else if oid == [0x55, 0x04, 0x06] {
"C"
} else if oid == [0x55, 0x04, 0x07] {
"L"
} else if oid == [0x55, 0x04, 0x08] {
"ST"
} else if oid == [0x55, 0x04, 0x0a] {
"O"
} else if oid == [0x55, 0x04, 0x0b] {
"OU"
} else {
continue;
};
parts.push(format!("{label}={value}"));
}
}
(!parts.is_empty()).then(|| parts.join(", "))
}
fn x509_days_in_month(year: i32, month: u32) -> Option<u32> {
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
Some(match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => return None,
})
}
fn x509_days_from_civil(year: i32, month: u32, day: u32) -> i64 {
let year = year - if month <= 2 { 1 } else { 0 };
let era = (if year >= 0 { year } else { year - 399 }) / 400;
let yoe = year - era * 400;
let month = i32::try_from(month).unwrap_or(0);
let day = i32::try_from(day).unwrap_or(0);
let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
i64::from(era * 146_097 + doe - 719_468)
}
fn x509_decimal(raw: &[u8]) -> Option<u32> {
let text = std::str::from_utf8(raw).ok()?;
text.parse().ok()
}
fn x509_time(tag: u8, raw: &[u8]) -> Option<(String, i64)> {
let year_digits = match tag {
0x17 => 2,
0x18 => 4,
_ => return None,
};
let base_digits = year_digits + 10;
if raw.len() < base_digits + 1 || !raw[..base_digits].iter().all(|byte| byte.is_ascii_digit()) {
return None;
}
let mut year = i32::try_from(x509_decimal(&raw[..year_digits])?).ok()?;
if year_digits == 2 {
year += if year >= 50 { 1900 } else { 2000 };
}
let mut cursor = year_digits;
let month = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
let day = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
let hour = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
let minute = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
let second = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
if day == 0
|| day > x509_days_in_month(year, month)?
|| hour > 23
|| minute > 59
|| second > 60
{
return None;
}
while cursor < raw.len() && matches!(raw[cursor], b'.' | b',') {
cursor += 1;
while cursor < raw.len() && raw[cursor].is_ascii_digit() {
cursor += 1;
}
}
let (offset_seconds, suffix) = match raw.get(cursor).copied()? {
b'Z' if cursor + 1 == raw.len() => (0i64, "Z".to_owned()),
sign @ (b'+' | b'-') if cursor + 5 == raw.len() => {
let zone_hour = x509_decimal(&raw[cursor + 1..cursor + 3])?;
let zone_minute = x509_decimal(&raw[cursor + 3..cursor + 5])?;
if zone_hour > 23 || zone_minute > 59 {
return None;
}
let seconds = i64::from(zone_hour * 3600 + zone_minute * 60)
* if sign == b'+' { 1 } else { -1 };
(
seconds,
format!(
"{}{:02}:{:02}",
if sign == b'+' { '+' } else { '-' },
zone_hour,
zone_minute
),
)
}
_ => return None,
};
let unix = x509_days_from_civil(year, month, day)
.checked_mul(86_400)?
.checked_add(i64::from(hour * 3600 + minute * 60 + second.min(59)))?
.checked_sub(offset_seconds)?;
let display = format!(
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}{suffix}"
);
Some((display, unix))
}
fn certificate_public_metadata(certificate: &[u8]) -> Result<CertificateMetadata> {
let covered_hosts = certificate_dns_names(certificate)?;
let mut reader = certificate;
let certs = rustls_pemfile::certs(&mut reader).collect::<io::Result<Vec<_>>>()?;
let leaf = certs.first().context("certificate chain is empty")?;
let der = leaf.as_ref();
let mut root_offset = 0;
let (tag, certificate_sequence) = der_tlv(der, &mut root_offset)?;
ensure!(
tag == 0x30 && root_offset == der.len(),
"invalid X.509 certificate DER"
);
let mut certificate_offset = 0;
let (tag, tbs) = der_tlv(certificate_sequence, &mut certificate_offset)?;
ensure!(tag == 0x30, "invalid X.509 TBSCertificate");
let mut tbs_offset = 0;
if tbs.first() == Some(&0xa0) {
let _ = der_tlv(tbs, &mut tbs_offset)?;
}
let _serial = der_tlv(tbs, &mut tbs_offset)?;
let _signature = der_tlv(tbs, &mut tbs_offset)?;
let (issuer_tag, issuer) = der_tlv(tbs, &mut tbs_offset)?;
ensure!(issuer_tag == 0x30, "invalid X.509 issuer");
let (validity_tag, validity) = der_tlv(tbs, &mut tbs_offset)?;
ensure!(validity_tag == 0x30, "invalid X.509 validity");
let mut validity_offset = 0;
let (not_before_tag, not_before_raw) = der_tlv(validity, &mut validity_offset)?;
let (not_after_tag, not_after_raw) = der_tlv(validity, &mut validity_offset)?;
let not_before = x509_time(not_before_tag, not_before_raw);
let not_after = x509_time(not_after_tag, not_after_raw);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|value| i64::try_from(value.as_secs()).unwrap_or(i64::MAX))
.unwrap_or(0);
let days_remaining = not_after.as_ref().map(|(_, unix)| {
let delta = unix.saturating_sub(now);
if delta >= 0 {
delta.saturating_add(86_399) / 86_400
} else {
-(delta.saturating_abs().saturating_add(86_399) / 86_400)
}
});
let expiry_status = match not_after.as_ref() {
Some((_, unix)) if *unix < now => "expired",
Some(_) if days_remaining.is_some_and(|days| days <= 30) => "warning",
Some(_) => "ok",
None => "unknown",
};
Ok(CertificateMetadata {
covered_hosts,
not_before: not_before.as_ref().map(|(value, _)| value.clone()),
not_after: not_after.as_ref().map(|(value, _)| value.clone()),
not_before_unix: not_before.map(|(_, value)| value),
not_after_unix: not_after.map(|(_, value)| value),
days_remaining,
issuer: certificate_issuer_name(issuer),
expiry_status,
})
}
/// Read DNS SubjectAltName coverage directly from an existing certificate file.
/// Used by the authenticated certificate inventory to enrich older/manual entries
/// that predate persisted `covered_hosts` metadata. Private key material is not read.
pub fn certificate_covered_hosts(directory: &Path, cert_file: &str) -> Result<Vec<String>> {
safe_name(cert_file)?;
let certificate = read_pem(&directory.join(cert_file), false)?;
certificate_dns_names(&certificate)
}
/// Read non-secret metadata directly from the configured public leaf certificate.
/// Private keys are never opened by this path.
pub fn certificate_metadata(directory: &Path, cert_file: &str) -> Result<CertificateMetadata> {
safe_name(cert_file)?;
let certificate = read_pem(&directory.join(cert_file), false)?;
certificate_public_metadata(&certificate)
}
fn parse_pair(certificate: &[u8], key: &[u8]) -> Result<CertifiedKey> {
let mut certificate_reader = certificate;
let certs = rustls_pemfile::certs(&mut certificate_reader).collect::<io::Result<Vec<_>>>()?;
ensure!(
!certs.is_empty() && certs.len() <= 16,
"certificate chain must contain 1..16 PEM certificates"
);
let mut key_reader = key;
let key = rustls_pemfile::private_key(&mut key_reader)?
.context("no supported PEM private key found")?;
let certified = CertifiedKey::from_der(certs, key, &rustls::crypto::ring::default_provider())?;
certified
.keys_match()
.context("certificate and private key do not match")?;
Ok(certified)
}
fn load_pair(directory: &Path, cert: &str, key: &str) -> Result<CertifiedKey> {
safe_name(cert)?;
safe_name(key)?;
parse_pair(
&read_pem(&directory.join(cert), false)?,
&read_pem(&directory.join(key), true)?,
)
}
/// Runs on a bounded control-plane blocking worker, never from a handshake.
pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result<Option<Arc<ServerConfig>>> {
settings.policy.validate()?;
let Some(directory) = directory else {
ensure!(
settings.default_certificate.is_none() && settings.certificates.is_empty(),
"certificate configuration requires a certificate directory"
);
return Ok(None);
};
let mut names = HashMap::<String, Arc<CertifiedKey>>::new();
let mut ids = HashSet::new();
let mut hosts = HashSet::new();
let mut default = None;
for cert in &settings.certificates {
safe_name(&cert.id)?;
ensure!(ids.insert(cert.id.clone()), "duplicate certificate id");
let key = load_pair(directory, &cert.cert_file, &cert.key_file)?;
for host in &cert.hosts {
let host = normalize_host_pattern(host)?;
ensure!(hosts.insert(host.clone()), "duplicate TLS hostname binding");
// Validate certificate/name compatibility with a concrete SNI name.
// rustls' stock resolver does not model routing wildcard precedence,
// so the immutable RustProxy resolver below owns exact/wildcard lookup.
let validation_name = host
.strip_prefix("*.")
.map(|base| format!("rustproxy-wildcard-check.{base}"))
.unwrap_or_else(|| host.clone());
let mut checker = ResolvesServerCertUsingSni::new();
checker
.add(&validation_name, key.clone())
.with_context(|| format!("certificate does not cover {host}"))?;
names.insert(host, Arc::new(key.clone()));
}
if settings.default_certificate.as_deref() == Some(cert.id.as_str()) {
default = Some(Arc::new(key));
}
}
if settings.default_certificate.is_some() {
ensure!(
default.is_some(),
"default_certificate references an unknown id"
);
}
let fallback = match default {
Some(key) => key,
None => Arc::new(load_pair(directory, "default.pem", "default.key")?),
};
Ok(Some(server_config(
Arc::new(Resolver { names, fallback }),
&settings.policy,
true,
)?))
}
/// Reuse the already validated immutable certificate resolver while narrowing
/// protocols/ciphers for one frontend. No certificate I/O occurs here.
pub fn variant(
base: &Arc<ServerConfig>,
policy: &ProtocolPolicy,
http2: bool,
) -> Result<Arc<ServerConfig>> {
server_config(base.cert_resolver.clone(), policy, http2)
}
+429
View File
@@ -0,0 +1,429 @@
mod tests {
use super::*;
#[test]
fn certificate_removal_requires_prior_unassignment_including_administration() {
let certificate = Certificate {
issuer: Some(Issuer {
provider: "http01".into(),
environment: "production".into(),
auto_renew: true,
}),
id: "managed".into(),
covered_hosts: Vec::new(),
hosts: vec!["panel.example.test".into()],
cert_file: "managed.pem".into(),
key_file: "managed.key".into(),
};
let mut previous = crate::config::Config::default();
previous.tls.certificates.push(certificate);
previous.administration.tls = true;
previous.administration.certificate_id = Some("managed".into());
let mut removed = previous.clone();
removed.administration.certificate_id = None;
removed.tls.certificates.clear();
assert!(validate_certificate_removals(&previous, &removed).is_err());
previous.administration.certificate_id = None;
previous.docker_agents.agents.push(crate::docker::Agent {
id: "docker1".into(),
name: "Docker 1".into(),
certificate_id: Some("managed".into()),
..Default::default()
});
let mut removed_from_docker = previous.clone();
removed_from_docker.docker_agents.agents[0].certificate_id = None;
removed_from_docker.tls.certificates.clear();
assert!(validate_certificate_removals(&previous, &removed_from_docker).is_err());
previous.docker_agents.agents[0].certificate_id = None;
let mut removed_after_unassign = previous.clone();
removed_after_unassign.tls.certificates.clear();
assert!(validate_certificate_removals(&previous, &removed_after_unassign).is_ok());
}
#[test]
fn x509_time_parser_handles_utc_and_generalized_time_with_offsets() {
let (epoch, unix) = x509_time(0x17, b"700101000000Z").unwrap();
assert_eq!(epoch, "1970-01-01T00:00:00Z");
assert_eq!(unix, 0);
let (future, unix) = x509_time(0x18, b"20500101000000+0100").unwrap();
assert_eq!(future, "2050-01-01T00:00:00+01:00");
assert_eq!(unix, 2_524_604_400);
assert!(x509_time(0x17, b"991332000000Z").is_none());
}
#[test]
fn public_certificate_metadata_exposes_san_and_validity_without_private_key() {
let generated = rcgen::generate_simple_self_signed(vec!["meta.localhost".into()]).unwrap();
let metadata = certificate_public_metadata(generated.cert.pem().as_bytes()).unwrap();
assert_eq!(metadata.covered_hosts, vec!["meta.localhost"]);
assert!(metadata.not_before_unix.is_some());
assert!(metadata.not_after_unix.is_some());
assert!(metadata.days_remaining.is_some());
assert_ne!(metadata.expiry_status, "unknown");
}
#[test]
fn external_pair_stages_generation_and_rolls_back_cleanly() {
let dir = tempfile::tempdir().unwrap();
let generated = rcgen::generate_simple_self_signed(vec![
"shared.example.test".into(),
"www.shared.example.test".into(),
])
.unwrap();
let (cert_file, key_file, covered_hosts) = stage_external_pair(
dir.path(),
"shared-cert",
generated.cert.pem().as_bytes(),
generated.key_pair.serialize_pem().as_bytes(),
)
.unwrap();
assert!(cert_file.starts_with("external-shared-cert-"));
assert!(cert_file.ends_with(".pem"));
assert!(key_file.starts_with("external-shared-cert-"));
assert!(key_file.ends_with(".key"));
assert!(dir.path().join(&cert_file).is_file());
assert!(dir.path().join(&key_file).is_file());
assert!(covered_hosts.contains(&"shared.example.test".to_string()));
assert!(covered_hosts.contains(&"www.shared.example.test".to_string()));
remove_external_pair(dir.path(), &cert_file, &key_file).unwrap();
assert!(!dir.path().join(cert_file).exists());
assert!(!dir.path().join(key_file).exists());
}
#[test]
fn external_pair_reconcile_is_idempotent_and_repairs_missing_generation() {
let dir = tempfile::tempdir().unwrap();
let generated =
rcgen::generate_simple_self_signed(vec!["reconcile.example.test".into()]).unwrap();
let certificate = generated.cert.pem();
let key = generated.key_pair.serialize_pem();
let (cert_file, key_file, _) = stage_external_pair(
dir.path(),
"reconcile-cert",
certificate.as_bytes(),
key.as_bytes(),
)
.unwrap();
let unchanged = reconcile_external_pair(
dir.path(),
"reconcile-cert",
Some((&cert_file, &key_file)),
certificate.as_bytes(),
key.as_bytes(),
)
.unwrap();
assert!(matches!(
unchanged,
ExternalPairDeployment::Unchanged { .. }
));
fs::remove_file(dir.path().join(&cert_file)).unwrap();
let repaired = reconcile_external_pair(
dir.path(),
"reconcile-cert",
Some((&cert_file, &key_file)),
certificate.as_bytes(),
key.as_bytes(),
)
.unwrap();
let ExternalPairDeployment::Staged {
cert_file: repaired_cert,
key_file: repaired_key,
..
} = repaired
else {
panic!("missing generation must be repaired by staging a new pair");
};
assert_ne!(repaired_cert, cert_file);
assert_ne!(repaired_key, key_file);
assert!(dir.path().join(repaired_cert).is_file());
assert!(dir.path().join(repaired_key).is_file());
}
#[test]
fn no_path_traversal() {
for path in [
"../secret",
"/etc/passwd",
"sub/key",
"sub\\key",
"",
"a..b",
] {
assert!(safe_name(path).is_err(), "{path}");
}
assert!(safe_name("app-v2.key").is_ok());
}
#[test]
fn default_is_persistent_and_tls_advertises_both_protocols() {
let dir = tempfile::tempdir().unwrap();
initialize_default(dir.path()).unwrap();
let before = fs::read(dir.path().join("default.pem")).unwrap();
initialize_default(dir.path()).unwrap();
assert_eq!(before, fs::read(dir.path().join("default.pem")).unwrap());
let config = compile(&Settings::default(), Some(dir.path()))
.unwrap()
.unwrap();
assert_eq!(
config.alpn_protocols,
vec![b"h2".to_vec(), b"http/1.1".to_vec()]
);
}
#[test]
fn policy_only_configuration_does_not_require_certificate_directory() {
let settings = Settings {
policy: ProtocolPolicy {
tls12: true,
tls13: false,
cipher_suites: Vec::new(),
},
..Settings::default()
};
assert!(!settings.is_empty());
assert!(compile(&settings, None).unwrap().is_none());
}
#[test]
fn protocol_policy_rejects_empty_unknown_and_mismatched_selections() {
assert!(ProtocolPolicy {
tls12: false,
tls13: false,
cipher_suites: vec![],
}
.validate()
.is_err());
assert!(ProtocolPolicy {
cipher_suites: vec!["NOT_A_RUSTLS_SUITE".into()],
..ProtocolPolicy::default()
}
.validate()
.is_err());
let suites = cipher_suite_names();
if let Some(tls13) = suites.iter().find(|suite| suite.starts_with("TLS13_")) {
assert!(ProtocolPolicy {
tls12: true,
tls13: false,
cipher_suites: vec![tls13.clone()],
}
.validate()
.is_err());
}
if let Some(tls12) = suites.iter().find(|suite| !suite.starts_with("TLS13_")) {
assert!(ProtocolPolicy {
tls12: false,
tls13: true,
cipher_suites: vec![tls12.clone()],
}
.validate()
.is_err());
}
let global = ProtocolPolicy {
tls12: true,
tls13: false,
cipher_suites: vec![],
};
assert!(ProtocolPolicy {
tls12: true,
tls13: false,
cipher_suites: vec![]
}
.validate_narrower_than(&global)
.is_ok());
assert!(ProtocolPolicy::default()
.validate_narrower_than(&global)
.is_err());
}
#[test]
fn administration_tls_supports_default_and_selected_certificate() {
let dir = tempfile::tempdir().unwrap();
initialize_default(dir.path()).unwrap();
generate(dir.path(), "panel", vec!["panel.localhost".into()]).unwrap();
let settings = Settings {
certificates: vec![Certificate {
issuer: None,
id: "panel".into(),
covered_hosts: Vec::new(),
hosts: vec!["panel.localhost".into()],
cert_file: "panel.pem".into(),
key_file: "panel.key".into(),
}],
..Settings::default()
};
let default_admin = crate::settings::Administration {
tls: true,
..crate::settings::Administration::default()
};
assert!(compile_admin(&settings, &default_admin, Some(dir.path()))
.unwrap()
.is_some());
let selected_admin = crate::settings::Administration {
tls: true,
certificate_id: Some("panel".into()),
..crate::settings::Administration::default()
};
assert!(compile_admin(&settings, &selected_admin, Some(dir.path()))
.unwrap()
.is_some());
}
#[test]
fn mismatched_key_and_invalid_sni_are_rejected() {
let a = rcgen::generate_simple_self_signed(vec!["a.localhost".into()]).unwrap();
let b = rcgen::generate_simple_self_signed(vec!["b.localhost".into()]).unwrap();
assert!(parse_pair(
a.cert.pem().as_bytes(),
b.key_pair.serialize_pem().as_bytes()
)
.is_err());
let dir = tempfile::tempdir().unwrap();
initialize_default(dir.path()).unwrap();
generate(dir.path(), "a", vec!["a.localhost".into()]).unwrap();
let mut settings = Settings {
default_certificate: None,
certificates: vec![Certificate {
issuer: None,
id: "a".into(),
covered_hosts: Vec::new(),
hosts: vec!["b.localhost".into()],
cert_file: "a.pem".into(),
key_file: "a.key".into(),
}],
..Settings::default()
};
assert!(compile(&settings, Some(dir.path())).is_err());
settings.certificates[0].hosts = vec!["a.localhost".into()];
assert!(compile(&settings, Some(dir.path())).is_ok());
settings.default_certificate = Some("missing".into());
assert!(compile(&settings, Some(dir.path())).is_err());
}
#[test]
fn named_generation_never_overwrites_keys() {
let dir = tempfile::tempdir().unwrap();
generate(dir.path(), "app", vec!["app.localhost".into()]).unwrap();
let before = fs::read(dir.path().join("app.key")).unwrap();
assert!(generate(dir.path(), "app", vec!["other.localhost".into()]).is_err());
assert_eq!(before, fs::read(dir.path().join("app.key")).unwrap());
}
#[test]
fn certificate_hostname_bindings_are_not_artificially_capped() {
let dir = tempfile::tempdir().unwrap();
let hosts = (0..40)
.map(|index| format!("host-{index}.example.test"))
.collect::<Vec<_>>();
generate(dir.path(), "many-hosts", hosts.clone()).unwrap();
let settings = Settings {
default_certificate: Some("many-hosts".into()),
certificates: vec![Certificate {
issuer: None,
id: "many-hosts".into(),
covered_hosts: hosts.clone(),
hosts: hosts.clone(),
cert_file: "many-hosts.pem".into(),
key_file: "many-hosts.key".into(),
}],
..Settings::default()
};
assert!(compile(&settings, Some(dir.path())).unwrap().is_some());
let pair = rcgen::generate_simple_self_signed(hosts.clone()).unwrap();
assert!(upload(
dir.path(),
pair.cert.pem().as_bytes(),
pair.key_pair.serialize_pem().as_bytes(),
hosts,
)
.is_ok());
}
#[test]
fn uploads_validate_key_and_names_and_store_only_private_immutable_files() {
let dir = tempfile::tempdir().unwrap();
initialize_default(dir.path()).unwrap();
let pair = rcgen::generate_simple_self_signed(vec![
"app.localhost".into(),
"cdn.localhost".into(),
])
.unwrap();
let pem = pair.cert.pem();
let key = pair.key_pair.serialize_pem();
assert!(upload(
dir.path(),
pem.as_bytes(),
key.as_bytes(),
vec!["other.localhost".into()]
)
.is_err());
assert!(upload(
dir.path(),
pem.as_bytes(),
b"invalid",
vec!["app.localhost".into()]
)
.is_err());
let a = upload(
dir.path(),
pem.as_bytes(),
key.as_bytes(),
vec!["app.localhost".into()],
)
.unwrap();
let b = upload(
dir.path(),
pem.as_bytes(),
key.as_bytes(),
vec!["app.localhost".into()],
)
.unwrap();
assert_ne!(a.id, b.id);
assert!(a.hosts.is_empty());
assert_eq!(a.covered_hosts, ["app.localhost", "cdn.localhost"]);
assert_eq!(
fs::read(dir.path().join(&a.key_file)).unwrap(),
key.as_bytes()
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(dir.path().join(&a.key_file))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
let inventory = Settings {
default_certificate: None,
certificates: vec![a.clone()],
..Settings::default()
};
assert!(compile(&inventory, Some(dir.path())).is_ok());
let vhost: crate::vhost::Vhost = serde_json::from_value(serde_json::json!({"id":"app",
"certificate_id":a.id,"frontend":{"hosts":["app.localhost"],"listener_ids":["https"]},
"backends":[{"id":"origin","url":"http://127.0.0.1:9001","allow_private":true}]}))
.unwrap();
let selected = for_vhosts(&inventory, &[vhost]).unwrap();
assert_eq!(selected.certificates[0].hosts, ["app.localhost"]);
assert!(compile(&selected, Some(dir.path())).is_ok());
}
#[test]
fn legacy_certificate_coverage_can_be_read_from_existing_pem() {
let dir = tempfile::tempdir().unwrap();
let pair = rcgen::generate_simple_self_signed(vec![
"app.example.org".into(),
"*.example.org".into(),
])
.unwrap();
fs::write(dir.path().join("legacy.pem"), pair.cert.pem()).unwrap();
assert_eq!(
certificate_covered_hosts(dir.path(), "legacy.pem").unwrap(),
["*.example.org", "app.example.org"]
);
assert!(certificate_covered_hosts(dir.path(), "../legacy.pem").is_err());
}
}
+77
View File
@@ -0,0 +1,77 @@
mod acme_tests {
use super::*;
#[test]
fn staged_acme_pair_requires_matching_key_and_requested_sans() {
let dir = tempfile::tempdir().unwrap();
let cert =
rcgen::generate_simple_self_signed(vec!["example.org".into(), "*.example.org".into()])
.unwrap();
store_pair(
dir.path(),
"acme-fixture",
cert.cert.pem().as_bytes(),
cert.key_pair.serialize_pem().as_bytes(),
)
.unwrap();
let requested = vec!["example.org".into(), "*.example.org".into()];
let item = validate_acme(
dir.path(),
"public".into(),
"acme-fixture.pem".into(),
"acme-fixture.key".into(),
&requested,
)
.unwrap();
assert_eq!(item.id, "public");
assert!(item.hosts.is_empty());
assert_eq!(item.covered_hosts, ["*.example.org", "example.org"]);
assert!(validate_acme(
dir.path(),
"public".into(),
"acme-fixture.pem".into(),
"acme-fixture.key".into(),
&["other.example.net".into()]
)
.is_err());
assert!(validate_acme(
dir.path(),
"public".into(),
"../acme-fixture.pem".into(),
"acme-fixture.key".into(),
&requested
)
.is_err());
assert!(validate_acme(
dir.path(),
"public".into(),
"acme-fixture.pem".into(),
"acme-fixture.key".into(),
&[]
)
.is_err());
}
#[test]
fn acme_validation_has_no_rustproxy_domain_count_ceiling() {
let dir = tempfile::tempdir().unwrap();
let requested = (0..24)
.map(|index| format!("acme-{index}.example.test"))
.collect::<Vec<_>>();
let cert = rcgen::generate_simple_self_signed(requested.clone()).unwrap();
store_pair(
dir.path(),
"acme-many",
cert.cert.pem().as_bytes(),
cert.key_pair.serialize_pem().as_bytes(),
)
.unwrap();
assert!(validate_acme(
dir.path(),
"many".into(),
"acme-many.pem".into(),
"acme-many.key".into(),
&requested,
)
.is_ok());
}
}
+392
View File
@@ -0,0 +1,392 @@
use crate::config::{normalize_host, normalize_host_pattern};
use anyhow::{ensure, Context, Result};
use rustls::{
server::{ClientHello, ResolvesServerCert, ResolvesServerCertUsingSni},
sign::CertifiedKey,
ServerConfig,
};
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet},
fs::{self, OpenOptions},
io::{self, Read, Write},
path::{Path, PathBuf},
sync::Arc,
};
use zeroize::Zeroizing;
const MAX_PEM: u64 = 131_072;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_certificate: Option<String>,
#[serde(default)]
pub certificates: Vec<Certificate>,
pub policy: ProtocolPolicy,
}
impl Default for Settings {
fn default() -> Self {
Self {
default_certificate: None,
certificates: Vec::new(),
policy: ProtocolPolicy::default(),
}
}
}
impl Settings {
pub fn is_empty(&self) -> bool {
self.default_certificate.is_none()
&& self.certificates.is_empty()
&& self.policy == ProtocolPolicy::default()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProtocolPolicy {
pub tls12: bool,
pub tls13: bool,
/// Rustls cipher-suite names. Empty keeps the ring provider defaults.
pub cipher_suites: Vec<String>,
}
impl Default for ProtocolPolicy {
fn default() -> Self {
Self {
tls12: true,
tls13: true,
cipher_suites: Vec::new(),
}
}
}
impl ProtocolPolicy {
pub fn validate(&self) -> Result<()> {
ensure!(
self.tls12 || self.tls13,
"at least one TLS protocol version must be enabled"
);
ensure!(
self.cipher_suites.len() <= 16,
"at most 16 TLS cipher suites may be selected"
);
let available = cipher_suite_names();
let mut seen = HashSet::new();
for suite in &self.cipher_suites {
ensure!(
suite.len() <= 96 && available.iter().any(|name| name == suite),
"unknown TLS cipher suite: {suite}"
);
ensure!(seen.insert(suite), "duplicate TLS cipher suite: {suite}");
}
let effective = if self.cipher_suites.is_empty() {
available
} else {
self.cipher_suites.clone()
};
ensure!(
effective.iter().any(|suite| {
(self.tls13 && suite.starts_with("TLS13_"))
|| (self.tls12 && !suite.starts_with("TLS13_"))
}),
"selected TLS cipher suites do not match the enabled protocol versions"
);
Ok(())
}
pub fn effective_cipher_suites(&self) -> Vec<String> {
let selected = if self.cipher_suites.is_empty() {
cipher_suite_names()
} else {
self.cipher_suites.clone()
};
selected
.into_iter()
.filter(|suite| {
(self.tls13 && suite.starts_with("TLS13_"))
|| (self.tls12 && !suite.starts_with("TLS13_"))
})
.collect()
}
pub fn allows_http3(&self) -> bool {
self.tls13
&& (self.cipher_suites.is_empty()
|| self
.cipher_suites
.iter()
.any(|suite| suite.starts_with("TLS13_")))
}
/// Per-vhost policy may only remove protocols/suites from the global policy.
/// This keeps the global setting a real security ceiling rather than a default
/// that an individual vhost can silently widen.
pub fn validate_narrower_than(&self, global: &Self) -> Result<()> {
self.validate()?;
global.validate()?;
ensure!(
!self.tls12 || global.tls12,
"per-vhost TLS policy cannot enable TLS 1.2 when it is disabled globally"
);
ensure!(
!self.tls13 || global.tls13,
"per-vhost TLS policy cannot enable TLS 1.3 when it is disabled globally"
);
if !global.cipher_suites.is_empty() {
ensure!(
!self.cipher_suites.is_empty(),
"per-vhost TLS policy must select from the globally allowed cipher suites"
);
for suite in &self.cipher_suites {
ensure!(
global.cipher_suites.iter().any(|allowed| allowed == suite),
"per-vhost TLS cipher suite is not allowed globally: {suite}"
);
}
}
Ok(())
}
}
pub fn cipher_suite_names() -> Vec<String> {
rustls::crypto::ring::default_provider()
.cipher_suites
.iter()
.map(|suite| format!("{:?}", suite.suite()))
.collect()
}
fn provider_for(policy: &ProtocolPolicy) -> Result<rustls::crypto::CryptoProvider> {
policy.validate()?;
let mut provider = rustls::crypto::ring::default_provider();
if !policy.cipher_suites.is_empty() {
provider.cipher_suites.retain(|suite| {
let name = format!("{:?}", suite.suite());
policy
.cipher_suites
.iter()
.any(|selected| selected == &name)
});
}
ensure!(
!provider.cipher_suites.is_empty(),
"TLS cipher selection is empty"
);
Ok(provider)
}
fn protocol_versions(policy: &ProtocolPolicy) -> Vec<&'static rustls::SupportedProtocolVersion> {
let mut versions = Vec::new();
if policy.tls13 {
versions.push(&rustls::version::TLS13);
}
if policy.tls12 {
versions.push(&rustls::version::TLS12);
}
versions
}
fn server_config(
resolver: Arc<dyn ResolvesServerCert>,
policy: &ProtocolPolicy,
http2: bool,
) -> Result<Arc<ServerConfig>> {
let provider = provider_for(policy)?;
let versions = protocol_versions(policy);
let mut config = ServerConfig::builder_with_provider(Arc::new(provider))
.with_protocol_versions(&versions)?
.with_no_client_auth()
.with_cert_resolver(resolver);
config.alpn_protocols = if http2 {
vec![b"h2".to_vec(), b"http/1.1".to_vec()]
} else {
vec![b"http/1.1".to_vec()]
};
config.max_early_data_size = 0;
Ok(Arc::new(config))
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Issuer {
pub provider: String,
pub environment: String,
#[serde(default = "renew_by_default")]
pub auto_renew: bool,
}
fn renew_by_default() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Certificate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer: Option<Issuer>,
pub id: String,
/// DNS SubjectAltName values extracted from the leaf certificate during
/// upload or ACME import. This is certificate coverage, not active SNI binding.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub covered_hosts: Vec<String>,
/// Active/configured SNI bindings. `for_vhosts` may rewrite this list.
pub hosts: Vec<String>,
pub cert_file: String,
pub key_file: String,
}
#[derive(Debug)]
struct Resolver {
names: HashMap<String, Arc<CertifiedKey>>,
fallback: Arc<CertifiedKey>,
}
impl Resolver {
fn named(&self, server_name: &str) -> Option<Arc<CertifiedKey>> {
let host = normalize_host(server_name).ok()?;
if let Some(key) = self.names.get(&host) {
return Some(key.clone());
}
let labels: Vec<&str> = host.split('.').collect();
for index in 1..labels.len().saturating_sub(1) {
let wildcard = format!("*.{}", labels[index..].join("."));
if let Some(key) = self.names.get(&wildcard) {
return Some(key.clone());
}
}
None
}
}
impl ResolvesServerCert for Resolver {
fn resolve(&self, hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
hello
.server_name()
.and_then(|name| self.named(name))
.or_else(|| Some(self.fallback.clone()))
}
}
pub fn validate_certificate_removals(
previous: &crate::config::Config,
next: &crate::config::Config,
) -> Result<()> {
let next_ids: HashSet<&str> = next
.tls
.certificates
.iter()
.map(|certificate| certificate.id.as_str())
.collect();
for certificate in &previous.tls.certificates {
if next_ids.contains(certificate.id.as_str()) {
continue;
}
let id = certificate.id.as_str();
let mut owners = Vec::new();
if previous.tls.default_certificate.as_deref() == Some(id) {
owners.push("default TLS certificate".to_owned());
}
if previous.administration.certificate_id.as_deref() == Some(id) {
owners.push("administration panel".to_owned());
}
owners.extend(
previous
.vhosts
.iter()
.filter(|host| host.certificate_id.as_deref() == Some(id))
.map(|host| format!("vhost {}", host.id)),
);
owners.extend(
previous
.redirects
.iter()
.filter(|host| host.certificate_id.as_deref() == Some(id))
.map(|host| format!("redirect {}", host.id)),
);
owners.extend(
previous
.not_found_hosts
.iter()
.filter(|host| host.certificate_id.as_deref() == Some(id))
.map(|host| format!("404 host {}", host.id)),
);
owners.extend(
previous
.docker_agents
.agents
.iter()
.filter(|agent| agent.certificate_id.as_deref() == Some(id))
.map(|agent| format!("Docker agent {}", agent.id)),
);
ensure!(owners.is_empty(), "certificate {id} is still assigned to {}; unassign it and save before removing the certificate", owners.join(", "));
}
Ok(())
}
pub fn safe_name(name: &str) -> Result<()> {
ensure!(
!name.is_empty()
&& name.len() <= 96
&& !name.contains("..")
&& name
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-' | b'_')),
"certificate filenames must be simple relative filenames, not paths"
);
Ok(())
}
fn read_pem(path: &Path, private: bool) -> Result<Zeroizing<Vec<u8>>> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options
.open(path)
.with_context(|| format!("cannot open certificate input {}", path.display()))?;
let meta = file.metadata()?;
ensure!(
meta.is_file() && meta.len() <= MAX_PEM,
"certificate inputs must be regular files up to 128 KiB"
);
#[cfg(unix)]
if private {
use std::os::unix::fs::PermissionsExt;
ensure!(
meta.permissions().mode() & 0o077 == 0,
"private key permissions must be 600: {}",
path.display()
);
}
#[cfg(not(unix))]
let _ = private;
let mut data = Zeroizing::new(Vec::new());
file.take(MAX_PEM + 1).read_to_end(&mut data)?;
ensure!(
data.len() <= MAX_PEM as usize,
"PEM file grew beyond its limit"
);
Ok(data)
}
fn der_tlv<'a>(input: &'a [u8], offset: &mut usize) -> Result<(u8, &'a [u8])> {
ensure!(*offset < input.len(), "truncated DER object");
let tag = input[*offset];
*offset += 1;
ensure!(*offset < input.len(), "truncated DER length");
let first = input[*offset];
*offset += 1;
let length = if first & 0x80 == 0 {
first as usize
} else {
let count = (first & 0x7f) as usize;
ensure!(count > 0 && count <= 4, "unsupported DER length");
ensure!(*offset + count <= input.len(), "truncated DER length");
let mut length = 0usize;
for byte in &input[*offset..*offset + count] {
length = length
.checked_mul(256)
.and_then(|value| value.checked_add(*byte as usize))
.context("DER length overflow")?;
}
*offset += count;
length
};
let end = (*offset)
.checked_add(length)
.context("DER length overflow")?;
ensure!(end <= input.len(), "truncated DER value");
let value = &input[*offset..end];
*offset = end;
Ok((tag, value))
}