This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 10:36:34 +02:00
1 parent 3b3f0db377
commit d940f9995f
47 files changed
+542 -187

No files matched your search

+5 -8
View File
@@ -310,16 +310,14 @@ fn certificate_public_metadata(certificate: &[u8]) -> Result<CertificateMetadata
/// 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)?;
let certificate = read_configured_pem(directory, 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)?;
let certificate = read_configured_pem(directory, cert_file, false)?;
certificate_public_metadata(&certificate)
}
@@ -340,11 +338,10 @@ fn parse_pair(certificate: &[u8], key: &[u8]) -> Result<CertifiedKey> {
Ok(certified)
}
fn load_pair(directory: &Path, cert: &str, key: &str) -> Result<CertifiedKey> {
safe_name(cert)?;
safe_name(key)?;
certificate_files_are_external(cert, key)?;
parse_pair(
&read_pem(&directory.join(cert), false)?,
&read_pem(&directory.join(key), true)?,
&read_configured_pem(directory, cert, false)?,
&read_configured_pem(directory, key, true)?,
)
}
/// Runs on a bounded control-plane blocking worker, never from a handshake.
+65
View File
@@ -158,6 +158,71 @@ mod tests {
assert!(safe_name(path).is_err(), "{path}");
}
assert!(safe_name("app-v2.key").is_ok());
assert!(certificate_files_are_external("app.pem", "app.key").is_ok());
assert!(certificate_files_are_external("app.pem", "/etc/tls/app.key").is_err());
}
#[cfg(unix)]
#[test]
fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() {
use std::os::unix::fs::{symlink, PermissionsExt};
let root = tempfile::tempdir().unwrap();
let cert_dir = root.path().join("rustproxy-certs");
fs::create_dir(&cert_dir).unwrap();
initialize_default(&cert_dir).unwrap();
let archive = root.path().join("archive/example.test");
let live = root.path().join("live/example.test");
fs::create_dir_all(&archive).unwrap();
fs::create_dir_all(&live).unwrap();
let first = rcgen::generate_simple_self_signed(vec!["example.test".into()]).unwrap();
fs::write(archive.join("fullchain1.pem"), first.cert.pem()).unwrap();
fs::write(archive.join("privkey1.pem"), first.key_pair.serialize_pem()).unwrap();
fs::set_permissions(archive.join("privkey1.pem"), fs::Permissions::from_mode(0o640)).unwrap();
symlink("../../archive/example.test/fullchain1.pem", live.join("fullchain.pem")).unwrap();
symlink("../../archive/example.test/privkey1.pem", live.join("privkey.pem")).unwrap();
let cert_path = live.join("fullchain.pem").display().to_string();
let key_path = live.join("privkey.pem").display().to_string();
assert!(certificate_files_are_external(&cert_path, &key_path).unwrap());
let settings = Settings {
default_certificate: Some("letsencrypt".into()),
certificates: vec![Certificate {
issuer: None,
id: "letsencrypt".into(),
covered_hosts: Vec::new(),
hosts: vec!["example.test".into()],
cert_file: cert_path.clone(),
key_file: key_path.clone(),
}],
..Settings::default()
};
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
assert_eq!(
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
vec!["example.test"]
);
let second = rcgen::generate_simple_self_signed(vec![
"example.test".into(),
"www.example.test".into(),
])
.unwrap();
fs::write(archive.join("fullchain2.pem"), second.cert.pem()).unwrap();
fs::write(archive.join("privkey2.pem"), second.key_pair.serialize_pem()).unwrap();
fs::set_permissions(archive.join("privkey2.pem"), fs::Permissions::from_mode(0o640)).unwrap();
fs::remove_file(live.join("fullchain.pem")).unwrap();
fs::remove_file(live.join("privkey.pem")).unwrap();
symlink("../../archive/example.test/fullchain2.pem", live.join("fullchain.pem")).unwrap();
symlink("../../archive/example.test/privkey2.pem", live.join("privkey.pem")).unwrap();
assert!(compile(&settings, Some(&cert_dir)).unwrap().is_some());
assert_eq!(
certificate_metadata(&cert_dir, &cert_path).unwrap().covered_hosts,
vec!["example.test", "www.example.test"]
);
}
#[test]
fn default_is_persistent_and_tls_advertises_both_protocols() {
+77 -6
View File
@@ -16,6 +16,7 @@ use std::{
use zeroize::Zeroizing;
const MAX_PEM: u64 = 131_072;
const MAX_CERTIFICATE_PATH: usize = 4096;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
@@ -324,7 +325,68 @@ pub fn safe_name(name: &str) -> Result<()> {
);
Ok(())
}
/// Validate how a configured certificate pair is addressed. Legacy/simple names
/// remain private to `<data-dir>/certs`; an explicitly selected external pair uses
/// two absolute paths. Mixing the two modes is rejected so the source is never
/// ambiguous.
pub fn certificate_files_are_external(cert_file: &str, key_file: &str) -> Result<bool> {
let cert_external = Path::new(cert_file).is_absolute();
let key_external = Path::new(key_file).is_absolute();
ensure!(
cert_external == key_external,
"certificate and private key must both use filenames from the RustProxy certificate directory or both use absolute external paths"
);
if cert_external {
ensure!(
!cert_file.is_empty()
&& !key_file.is_empty()
&& cert_file.len() <= MAX_CERTIFICATE_PATH
&& key_file.len() <= MAX_CERTIFICATE_PATH,
"external certificate paths must be absolute paths up to 4096 bytes"
);
Ok(true)
} else {
safe_name(cert_file)?;
safe_name(key_file)?;
Ok(false)
}
}
fn configured_certificate_path(directory: &Path, value: &str) -> Result<PathBuf> {
if Path::new(value).is_absolute() {
ensure!(
!value.is_empty() && value.len() <= MAX_CERTIFICATE_PATH,
"external certificate path must be an absolute path up to 4096 bytes"
);
// Certbot's live/ entries are symlinks to archive/. Resolve them on every
// reload, then open the final regular file with O_NOFOLLOW in read_pem.
return fs::canonicalize(value)
.with_context(|| format!("cannot resolve external certificate path {value}"));
}
safe_name(value)?;
Ok(directory.join(value))
}
fn read_configured_pem(
directory: &Path,
value: &str,
private: bool,
) -> Result<Zeroizing<Vec<u8>>> {
let external = Path::new(value).is_absolute();
let path = configured_certificate_path(directory, value)?;
read_pem_with_policy(&path, private, external)
}
fn read_pem(path: &Path, private: bool) -> Result<Zeroizing<Vec<u8>>> {
read_pem_with_policy(path, private, false)
}
fn read_pem_with_policy(
path: &Path,
private: bool,
external_group_read: bool,
) -> Result<Zeroizing<Vec<u8>>> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
@@ -343,14 +405,23 @@ fn read_pem(path: &Path, private: bool) -> Result<Zeroizing<Vec<u8>>> {
#[cfg(unix)]
if private {
use std::os::unix::fs::PermissionsExt;
ensure!(
meta.permissions().mode() & 0o077 == 0,
"private key permissions must be 600: {}",
path.display()
);
let mode = meta.permissions().mode();
if external_group_read {
ensure!(
mode & 0o137 == 0,
"external private key may be owner/group-readable but must not be executable, group-writable, or world-accessible: {}",
path.display()
);
} else {
ensure!(
mode & 0o077 == 0,
"private key permissions must not grant group/world access: {}",
path.display()
);
}
}
#[cfg(not(unix))]
let _ = private;
let _ = (private, external_group_read);
let mut data = Zeroizing::new(Vec::new());
file.take(MAX_PEM + 1).read_to_end(&mut data)?;
ensure!(