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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user