v0.28.1
This commit is contained in:
1 parent
3b3f0db377
commit
d940f9995f
47 files changed
+542
-187
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
Reference in new issue
Block a user