diff --git a/CHANGELOG.md b/CHANGELOG.md index df8162b..7628f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- Custom file permissions for key and certificate files (`key_mode`, `key_owner`, `key_group`, `cert_mode`, `cert_owner`, `cert_group`). Permissions are applied on every renewal run, even if the certificate is not due for renewal. + ### Fixes ### Changes diff --git a/Cargo.lock b/Cargo.lock index dc14d07..87488cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,6 +359,7 @@ dependencies = [ "hyper", "hyper-util", "instant-acme", + "libc", "pem", "rcgen 0.13.2", "ring", diff --git a/Cargo.toml b/Cargo.toml index 39dc2a4..4e35d46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,5 +64,8 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" # Trait async support async-trait = "0.1" +# Unix permissions +libc = "0.2" + # systemd D-Bus zbus = "5" diff --git a/README.md b/README.md index 845aed3..2d9e45b 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,25 @@ If neither `solver` nor `solvers` is set, the global `default_solver` is used: default_solver = "dns" ``` +### File permissions + +Key and certificate files support custom permissions. Mode is specified as an octal string. Owner and group are resolved by name: + +```toml +[[certificate]] +name = "mail" +key_path = "/etc/certforge/keys/mail.key" +key_mode = "0640" +key_owner = "root" +key_group = "postfix" +cert_path = "/etc/certforge/certs/mail.pem" +cert_mode = "0644" +cert_owner = "root" +cert_group = "postfix" +``` + +Defaults: key mode `0600`, cert mode `0644`. Owner and group are not changed unless explicitly set. Permissions are applied on every `certforge renew` run, even when no certificates need renewal. + ### ACME profiles Some ACME providers require a specific profile for certain certificate types. Set `profile` on a certificate to select one. For example, Let's Encrypt requires the `shortlived` profile for IP address certificates (see [Let's Encrypt profiles](https://letsencrypt.org/docs/profiles/)). diff --git a/debian/changelog b/debian/changelog index 744dbbb..000a7c3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,6 @@ certforge (0.7.0-1) unstable; urgency=low - * No changes + * Custom file permissions for key and certificate files -- Roman Kivalin Mon, 14 Apr 2026 00:00:00 +0000 diff --git a/examples/config.toml b/examples/config.toml index 5766617..3f85bbb 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -81,7 +81,13 @@ dns = "main" # references [dns.main] # key_type = "ecdsa-p256" # ecdsa-p256, ecdsa-p384 # key_credential = "/etc/credstore.encrypted/mail-tls-key" # # key_path = "/etc/certforge/keys/mail.key" +# # key_mode = "0640" # file mode (default: 0600) +# # key_owner = "root" # file owner (default: unchanged) +# # key_group = "postfix" # file group (default: unchanged) # cert_path = "/etc/certforge/certs/mail.pem" +# # cert_mode = "0644" # file mode (default: 0644) +# # cert_owner = "root" +# # cert_group = "postfix" # renew_before_days = 30 # rotate_key = false # # Named hooks (deduplicated if multiple certs reference the same hook) diff --git a/src/config.rs b/src/config.rs index d6e0d6e..e0c0d66 100644 --- a/src/config.rs +++ b/src/config.rs @@ -187,8 +187,26 @@ pub struct CertificateConfig { pub key_credential: Option, pub key_path: Option, + /// File mode for key_path (octal string, e.g. "0640"). Default: "0600". + #[serde(default)] + pub key_mode: Option, + /// Owner user for key_path. + #[serde(default)] + pub key_owner: Option, + /// Owner group for key_path. + #[serde(default)] + pub key_group: Option, pub cert_path: PathBuf, + /// File mode for cert_path (octal string, e.g. "0644"). Default: "0644". + #[serde(default)] + pub cert_mode: Option, + /// Owner user for cert_path. + #[serde(default)] + pub cert_owner: Option, + /// Owner group for cert_path. + #[serde(default)] + pub cert_group: Option, /// ACME profile to use for the order. /// Some providers require a specific profile for certain certificate types @@ -357,6 +375,11 @@ pub struct NamedHookConfig { pub hook: HookConfig, } +/// Parse an octal mode string (e.g., "0640") to a u32. +pub fn parse_mode(s: &str) -> Result { + u32::from_str_radix(s, 8).map_err(|_| Error::Config(format!("invalid file mode '{s}' (expected octal, e.g. \"0640\")"))) +} + impl CertificateConfig { /// Resolve the solver name for the i-th domain. /// Priority: solvers[i] > solver > default_solver. @@ -503,6 +526,16 @@ impl Config { cert.name ))); } + if let Some(mode) = &cert.key_mode { + parse_mode(mode).map_err(|_| Error::Config(format!( + "certificate {}: invalid key_mode '{mode}'", cert.name + )))?; + } + if let Some(mode) = &cert.cert_mode { + parse_mode(mode).map_err(|_| Error::Config(format!( + "certificate {}: invalid cert_mode '{mode}'", cert.name + )))?; + } // solver and solvers are mutually exclusive if cert.solver.is_some() && cert.solvers.is_some() { diff --git a/src/main.rs b/src/main.rs index 6b5e147..7d7a560 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod dns; mod error; mod hooks; mod keys; +mod permissions; mod renew; mod solver; mod state; diff --git a/src/permissions.rs b/src/permissions.rs new file mode 100644 index 0000000..5be8efe --- /dev/null +++ b/src/permissions.rs @@ -0,0 +1,131 @@ +use std::path::Path; + +use crate::config::{self, CertificateConfig}; +use crate::error::{Error, Result}; + +/// Apply configured file permissions (mode, owner, group) to a path. +/// Only makes system calls when the current state differs from the desired state. +/// Skips if the file does not exist. +#[cfg(unix)] +pub fn apply( + path: &Path, + mode: Option<&str>, + default_mode: u32, + owner: Option<&str>, + group: Option<&str>, +) -> Result<()> { + if !path.exists() { + return Ok(()); + } + + let desired_mode = match mode { + Some(s) => config::parse_mode(s)?, + None => default_mode, + }; + + // Check and apply mode + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(path)?; + let current_mode = metadata.permissions().mode() & 0o7777; + if current_mode != desired_mode { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(desired_mode))?; + tracing::info!(path = %path.display(), from = format!("{current_mode:04o}"), to = format!("{desired_mode:04o}"), "updated file mode"); + } + + // Check and apply ownership + if owner.is_some() || group.is_some() { + use std::os::unix::fs::MetadataExt; + let current_uid = metadata.uid(); + let current_gid = metadata.gid(); + + let desired_uid = match owner { + Some(name) => Some(resolve_uid(name)?), + None => None, + }; + let desired_gid = match group { + Some(name) => Some(resolve_gid(name)?), + None => None, + }; + + let uid_changed = desired_uid.is_some_and(|u| u != current_uid); + let gid_changed = desired_gid.is_some_and(|g| g != current_gid); + + if uid_changed || gid_changed { + chown(path, desired_uid, desired_gid)?; + tracing::info!( + path = %path.display(), + uid = ?desired_uid.filter(|_| uid_changed), + gid = ?desired_gid.filter(|_| gid_changed), + "updated file ownership" + ); + } + } + + Ok(()) +} + +/// Ensure file permissions for a certificate's key and cert files. +pub fn ensure_permissions(cert_config: &CertificateConfig) -> Result<()> { + #[cfg(unix)] + { + if let Some(key_path) = &cert_config.key_path { + apply( + key_path, + cert_config.key_mode.as_deref(), + 0o600, + cert_config.key_owner.as_deref(), + cert_config.key_group.as_deref(), + )?; + } + apply( + &cert_config.cert_path, + cert_config.cert_mode.as_deref(), + 0o644, + cert_config.cert_owner.as_deref(), + cert_config.cert_group.as_deref(), + )?; + } + Ok(()) +} + +#[cfg(unix)] +fn resolve_uid(name: &str) -> Result { + use std::ffi::CString; + let c_name = CString::new(name) + .map_err(|_| Error::Config(format!("invalid user name '{name}'")))?; + let pw = unsafe { libc::getpwnam(c_name.as_ptr()) }; + if pw.is_null() { + return Err(Error::Config(format!("user '{name}' not found"))); + } + Ok(unsafe { (*pw).pw_uid }) +} + +#[cfg(unix)] +fn resolve_gid(name: &str) -> Result { + use std::ffi::CString; + let c_name = CString::new(name) + .map_err(|_| Error::Config(format!("invalid group name '{name}'")))?; + let gr = unsafe { libc::getgrnam(c_name.as_ptr()) }; + if gr.is_null() { + return Err(Error::Config(format!("group '{name}' not found"))); + } + Ok(unsafe { (*gr).gr_gid }) +} + +#[cfg(unix)] +fn chown(path: &Path, uid: Option, gid: Option) -> Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let c_path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid path")))?; + + let uid = uid.map(|u| u as libc::uid_t).unwrap_or(u32::MAX); + let gid = gid.map(|g| g as libc::gid_t).unwrap_or(u32::MAX); + + let ret = unsafe { libc::chown(c_path.as_ptr(), uid, gid) }; + if ret != 0 { + return Err(Error::Io(std::io::Error::last_os_error())); + } + Ok(()) +} diff --git a/src/renew.rs b/src/renew.rs index b2aeb77..f72df2c 100644 --- a/src/renew.rs +++ b/src/renew.rs @@ -77,6 +77,17 @@ pub async fn run( let mut state_dirty = false; let mut pending_hooks: Vec = Vec::new(); + // Ensure file permissions on existing files before renewal + for cert_config in &config.certificates { + if let Some(filter) = name_filter + && cert_config.name != filter { + continue; + } + if let Err(e) = crate::permissions::ensure_permissions(cert_config) { + tracing::warn!(name = %cert_config.name, error = %e, "failed to set file permissions"); + } + } + let acme = AcmeClient::new(&config.acme).await?; for cert_config in &config.certificates { @@ -263,6 +274,9 @@ async fn renew_certificate( save_key(&key, cert_config).await?; + // Apply file permissions + crate::permissions::ensure_permissions(cert_config)?; + // Publish DANE TLSA records if !cert_config.dane.is_empty() { let cert_info = CertInfo::from_pem(&cert_pem)?; @@ -373,11 +387,6 @@ async fn save_key(key: &CertKeyPair, cert_config: &CertificateConfig) -> Result< std::fs::create_dir_all(parent)?; } std::fs::write(key_path, &pem)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600))?; - } tracing::info!(path = %key_path.display(), "saved private key"); }