mirror of
https://github.com/rkivalin/certforge.git
synced 2026-10-12 16:00:45 +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
@@ -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
|
||||
|
||||
Generated
+1
@@ -359,6 +359,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"instant-acme",
|
||||
"libc",
|
||||
"pem",
|
||||
"rcgen 0.13.2",
|
||||
"ring",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/)).
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
certforge (0.7.0-1) unstable; urgency=low
|
||||
|
||||
* No changes
|
||||
* Custom file permissions for key and certificate files
|
||||
|
||||
-- Roman Kivalin <roman@shl.dev> Mon, 14 Apr 2026 00:00:00 +0000
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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