mirror of
https://github.com/rkivalin/certforge.git
synced 2026-10-16 16:00:50 +00:00
Add custom file permissions for key and certificate files
Support key_mode, key_owner, key_group and cert_mode, cert_owner, cert_group per certificate. Mode is an octal string (e.g. "0640"), owner/group are resolved by name via getpwnam/getgrnam. Permissions are checked before applying — system calls are only made when the current state differs from the desired state, and changes are logged. Runs on every renew for existing files, not just when certificates are renewed. Defaults: key 0600, cert 0644, owner/group unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
aeb17eef08
commit
c717da348a
@@ -187,8 +187,26 @@ pub struct CertificateConfig {
|
||||
|
||||
pub key_credential: Option<PathBuf>,
|
||||
pub key_path: Option<PathBuf>,
|
||||
/// File mode for key_path (octal string, e.g. "0640"). Default: "0600".
|
||||
#[serde(default)]
|
||||
pub key_mode: Option<String>,
|
||||
/// Owner user for key_path.
|
||||
#[serde(default)]
|
||||
pub key_owner: Option<String>,
|
||||
/// Owner group for key_path.
|
||||
#[serde(default)]
|
||||
pub key_group: Option<String>,
|
||||
|
||||
pub cert_path: PathBuf,
|
||||
/// File mode for cert_path (octal string, e.g. "0644"). Default: "0644".
|
||||
#[serde(default)]
|
||||
pub cert_mode: Option<String>,
|
||||
/// Owner user for cert_path.
|
||||
#[serde(default)]
|
||||
pub cert_owner: Option<String>,
|
||||
/// Owner group for cert_path.
|
||||
#[serde(default)]
|
||||
pub cert_group: Option<String>,
|
||||
|
||||
/// 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> {
|
||||
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() {
|
||||
|
||||
@@ -8,6 +8,7 @@ mod dns;
|
||||
mod error;
|
||||
mod hooks;
|
||||
mod keys;
|
||||
mod permissions;
|
||||
mod renew;
|
||||
mod solver;
|
||||
mod state;
|
||||
|
||||
@@ -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<u32> {
|
||||
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<u32> {
|
||||
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<u32>, gid: Option<u32>) -> 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(())
|
||||
}
|
||||
+14
-5
@@ -77,6 +77,17 @@ pub async fn run(
|
||||
let mut state_dirty = false;
|
||||
let mut pending_hooks: Vec<String> = 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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user