Initial implementation of certforge ACME certificate manager

Rust CLI tool for automated ACME certificate management with:
- DNS-01 challenge via RFC 2136 dynamic DNS updates with TSIG auth
- DANE TLSA record publication (RFC 6698/7671/7672)
- systemd credential integration (systemd-creds encrypt/decrypt)
- systemd unit management via D-Bus (zbus)
- Post-renewal hooks (systemctl reload/restart, arbitrary commands)
- Key rotation with DANE pre-publication protocol
- TOML configuration with per-zone DNS overrides

Tested against Let's Encrypt staging. 12 unit tests, zero warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Roman Kivalin
2026-04-12 20:07:27 +02:00
co-authored by Claude Opus 4.6
commit c1fdfba3fe
22 changed files with 6113 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
/testing
Generated
+3138
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "certforge"
version = "0.1.0"
edition = "2024"
[dependencies]
# ACME
instant-acme = "0.8"
# DNS
hickory-proto = { version = "0.25", features = ["dnssec-ring"] }
hickory-client = { version = "0.25", features = ["dnssec-ring"] }
# Crypto / Certificates
rcgen = "0.13"
ring = "0.17"
x509-parser = "0.16"
pem = "3"
sha2 = "0.10"
base64 = "0.22"
hex = "0.4"
rustls-pki-types = "1"
# Async
tokio = { version = "1", features = ["full"] }
# CLI
clap = { version = "4", features = ["derive"] }
# Config
serde = { version = "1", features = ["derive"] }
toml = "0.8"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
thiserror = "2"
anyhow = "1"
# Time
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
# State persistence
serde_json = "1"
# systemd D-Bus
zbus = "5"
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Certforge ACME certificate renewal
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certforge renew
# Hardening
ProtectSystem=strict
ReadWritePaths=/etc/certforge/certs /var/lib/certforge /etc/credstore.encrypted
PrivateTmp=yes
NoNewPrivileges=yes
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Certforge daily certificate check
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=1800
Persistent=true
[Install]
WantedBy=timers.target
+85
View File
@@ -0,0 +1,85 @@
# Example certforge configuration
#
# Full documentation: see the project README
[acme]
directory_url = "https://acme-v02.api.letsencrypt.org/directory"
# Use staging for testing:
# directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
# ACME account key: stored as encrypted systemd credential
account_key_credential = "/etc/credstore.encrypted/acme-account-key"
# Or as a plain file:
# account_key_path = "/etc/certforge/account.key"
contact = ["mailto:admin@example.com"]
# Default DNS server for RFC 2136 updates
[dns.defaults]
server = "ns1.example.com"
tsig_key_credential = "/etc/credstore.encrypted/dns-tsig-key"
# tsig_key_path = "/etc/certforge/tsig.key"
tsig_key_name = "certforge-update."
tsig_algorithm = "hmac-sha256"
port = 53
protocol = "tcp"
# Per-zone DNS overrides (optional)
# [dns.zones."example.org"]
# server = "ns2.example.org"
# tsig_key_credential = "/etc/credstore.encrypted/dns-tsig-org"
# Mail server certificate
[[certificate]]
name = "mail"
domains = ["mail.example.com"]
key_type = "ecdsa-p256"
key_credential = "/etc/credstore.encrypted/mail-tls-key"
cert_path = "/etc/certforge/certs/mail.pem"
renew_before_days = 30
rotate_key = false
# DANE TLSA for SMTP (port 25)
[[certificate.dane]]
usage = "ee"
selector = "spki"
matching = "sha256"
names = ["_25._tcp.mail.example.com"]
ttl = 300
pre_publish = true
# DANE TLSA for IMAP (port 993)
[[certificate.dane]]
usage = "ee"
selector = "spki"
matching = "sha256"
names = ["_993._tcp.mail.example.com"]
ttl = 300
[[certificate.hook]]
type = "systemd-reload"
unit = "postfix.service"
[[certificate.hook]]
type = "systemd-restart"
unit = "dovecot.service"
# Web server certificate
[[certificate]]
name = "web"
domains = ["example.com", "www.example.com"]
key_type = "ecdsa-p256"
key_credential = "/etc/credstore.encrypted/web-tls-key"
cert_path = "/etc/certforge/certs/web.pem"
renew_before_days = 30
[[certificate.dane]]
usage = "ee"
selector = "spki"
matching = "sha256"
names = ["_443._tcp.example.com", "_443._tcp.www.example.com"]
ttl = 3600
[[certificate.hook]]
type = "systemd-reload"
unit = "nginx.service"
+222
View File
@@ -0,0 +1,222 @@
use std::time::Duration;
use instant_acme::{
Account, AccountCredentials, ChallengeType, Identifier, NewAccount, NewOrder, Order,
OrderStatus,
};
use crate::config::AcmeConfig;
use crate::credentials;
use crate::error::{Error, Result};
/// ACME client wrapping instant-acme with credential management.
pub struct AcmeClient {
account: Account,
}
/// Information about a pending DNS-01 challenge.
pub struct DnsChallenge {
/// The domain being validated
pub domain: String,
/// The full DNS name for the TXT record: _acme-challenge.<domain>
pub txt_name: String,
/// The TXT record value (base64url-encoded SHA-256 of key authorization)
pub txt_value: String,
}
impl AcmeClient {
/// Load or create an ACME account.
pub async fn new(config: &AcmeConfig) -> Result<Self> {
let cred_data = credentials::load_secret(
config.account_key_credential.as_deref(),
config.account_key_path.as_deref(),
)
.await;
match cred_data {
Ok(data) => {
let creds: AccountCredentials = serde_json::from_slice(&data).map_err(|e| {
Error::Config(format!("invalid account credentials: {e}"))
})?;
let account = Account::builder()
.map_err(|e| Error::Acme(e))?
.from_credentials(creds)
.await?;
tracing::info!(id = %account.id(), "loaded existing ACME account");
Ok(Self { account })
}
Err(_) => {
tracing::info!(directory = %config.directory_url, "creating new ACME account");
let (account, creds) = Account::builder()
.map_err(|e| Error::Acme(e))?
.create(
&NewAccount {
contact: &config
.contact
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>(),
terms_of_service_agreed: true,
only_return_existing: false,
},
config.directory_url.clone(),
None,
)
.await?;
tracing::info!(id = %account.id(), "created new ACME account");
let creds_json = serde_json::to_vec(&creds)
.map_err(|e| Error::Config(format!("failed to serialize credentials: {e}")))?;
if let Some(cred_path) = &config.account_key_credential {
credentials::encrypt_credential("acme-account-key", &creds_json, cred_path)
.await?;
tracing::info!(path = %cred_path.display(), "saved account credentials (encrypted)");
} else if let Some(file_path) = &config.account_key_path {
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(file_path, &creds_json)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
file_path,
std::fs::Permissions::from_mode(0o600),
)?;
}
tracing::info!(path = %file_path.display(), "saved account credentials");
}
Ok(Self { account })
}
}
}
/// Create a new certificate order, extract DNS-01 challenge info, and set challenges ready.
///
/// The caller should publish DNS TXT records for the returned challenges BEFORE calling
/// this method. This method collects challenge info, publishes DNS, then sets challenges ready.
///
/// Returns the order and challenge info needed for DNS TXT records.
pub async fn create_order(&self, domains: &[String]) -> Result<(Order, Vec<DnsChallenge>)> {
let identifiers: Vec<Identifier> = domains
.iter()
.map(|d| Identifier::Dns(d.clone()))
.collect();
let order = self
.account
.new_order(&NewOrder::new(&identifiers))
.await?;
Ok((order, Vec::new()))
}
/// Collect DNS-01 challenge info from an order's authorizations.
pub async fn collect_challenges(order: &mut Order) -> Result<Vec<DnsChallenge>> {
let mut challenges = Vec::new();
let mut auths = order.authorizations();
while let Some(auth_result) = auths.next().await {
let mut auth = auth_result?;
let domain = auth.identifier().to_string();
let dns01 = auth.challenge(ChallengeType::Dns01).ok_or_else(|| {
Error::Acme(instant_acme::Error::Other(
format!("no DNS-01 challenge for {domain}").into(),
))
})?;
let key_auth = dns01.key_authorization();
let txt_value = key_auth.dns_value();
challenges.push(DnsChallenge {
txt_name: format!("_acme-challenge.{domain}"),
domain,
txt_value,
});
}
Ok(challenges)
}
/// Set all DNS-01 challenges as ready (call after publishing TXT records).
pub async fn set_challenges_ready(order: &mut Order) -> Result<()> {
let mut auths = order.authorizations();
while let Some(auth_result) = auths.next().await {
let mut auth = auth_result?;
if let Some(mut dns01) = auth.challenge(ChallengeType::Dns01) {
dns01.set_ready().await?;
}
}
Ok(())
}
/// Wait for the order to become ready, then finalize with a CSR.
pub async fn finalize_order(order: &mut Order, csr_der: &[u8]) -> Result<()> {
let mut retries = 20;
loop {
let state = order.refresh().await?;
match state.status {
OrderStatus::Ready => break,
OrderStatus::Pending => {
if retries == 0 {
return Err(Error::Acme(instant_acme::Error::Other(
"order still pending after max retries".into(),
)));
}
retries -= 1;
tokio::time::sleep(Duration::from_secs(3)).await;
}
OrderStatus::Invalid => {
return Err(Error::Acme(instant_acme::Error::Other(
"order became invalid".into(),
)));
}
OrderStatus::Processing | OrderStatus::Valid => break,
}
}
order.finalize_csr(csr_der).await?;
Ok(())
}
/// Wait for the certificate to be available and download it.
pub async fn download_certificate(order: &mut Order) -> Result<String> {
let mut retries = 20;
loop {
let state = order.refresh().await?;
match state.status {
OrderStatus::Valid => {
let cert = order.certificate().await?.ok_or_else(|| {
Error::Acme(instant_acme::Error::Other(
"order valid but no certificate available".into(),
))
})?;
return Ok(cert);
}
OrderStatus::Processing => {
if retries == 0 {
return Err(Error::Acme(instant_acme::Error::Other(
"certificate still processing after max retries".into(),
)));
}
retries -= 1;
tokio::time::sleep(Duration::from_secs(3)).await;
}
status => {
return Err(Error::Acme(instant_acme::Error::Other(
format!("unexpected order status: {status:?}").into(),
)));
}
}
}
}
pub fn account(&self) -> &Account {
&self.account
}
}
+135
View File
@@ -0,0 +1,135 @@
use std::path::Path;
use x509_parser::prelude::*;
use crate::error::{Error, Result};
/// Parsed certificate information.
pub struct CertInfo {
/// The full certificate chain PEM
#[allow(dead_code)]
pub pem_chain: String,
/// DER-encoded leaf certificate
pub leaf_der: Vec<u8>,
/// Not-after (expiry) timestamp
pub not_after: chrono::DateTime<chrono::Utc>,
/// Subject Alternative Names
pub san: Vec<String>,
/// SubjectPublicKeyInfo DER bytes
pub spki_der: Vec<u8>,
}
impl CertInfo {
/// Load and parse a certificate from a PEM file.
pub fn load(path: &Path) -> Result<Self> {
let pem_chain = std::fs::read_to_string(path).map_err(|e| {
Error::Certificate(format!("failed to read cert {}: {e}", path.display()))
})?;
Self::from_pem(&pem_chain)
}
/// Parse a certificate from PEM data.
pub fn from_pem(pem_data: &str) -> Result<Self> {
let pem_blocks: Vec<::pem::Pem> = ::pem::parse_many(pem_data.as_bytes())
.map_err(|e| Error::Certificate(format!("failed to parse PEM: {e}")))?;
if pem_blocks.is_empty() {
return Err(Error::Certificate("no certificates found in PEM data".into()));
}
let leaf_der = pem_blocks[0].contents().to_vec();
let (_, cert) = X509Certificate::from_der(&leaf_der)
.map_err(|e| Error::Certificate(format!("failed to parse X.509: {e}")))?;
let not_after = asn1_time_to_chrono(cert.validity().not_after)?;
let san = extract_san(&cert);
let spki_der = cert.public_key().raw.to_vec();
Ok(Self {
pem_chain: pem_data.to_string(),
leaf_der,
not_after,
san,
spki_der,
})
}
/// Check if the certificate expires within the given number of days.
pub fn expires_within_days(&self, days: u32) -> bool {
let now = chrono::Utc::now();
let threshold = now + chrono::Duration::days(days as i64);
self.not_after <= threshold
}
/// Days until expiry (negative if already expired).
pub fn days_until_expiry(&self) -> i64 {
let now = chrono::Utc::now();
(self.not_after - now).num_days()
}
}
fn extract_san(cert: &X509Certificate) -> Vec<String> {
let mut names = Vec::new();
if let Ok(Some(san_ext)) = cert.subject_alternative_name() {
for name in &san_ext.value.general_names {
match name {
GeneralName::DNSName(dns) => names.push(dns.to_string()),
GeneralName::IPAddress(ip) => {
// IP addresses come as byte slices
if ip.len() == 4 {
names.push(format!("{}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3]));
} else if ip.len() == 16 {
let parts: Vec<String> = ip
.chunks(2)
.map(|c| format!("{:02x}{:02x}", c[0], c[1]))
.collect();
names.push(parts.join(":"));
}
}
_ => {}
}
}
}
names
}
fn asn1_time_to_chrono(time: ASN1Time) -> Result<chrono::DateTime<chrono::Utc>> {
let ts = time.timestamp();
chrono::DateTime::from_timestamp(ts, 0)
.ok_or_else(|| Error::Certificate(format!("invalid timestamp: {ts}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn generate_test_cert() -> String {
use rcgen::{CertificateParams, KeyPair};
let mut params = CertificateParams::new(vec!["test.example.com".to_string()]).unwrap();
params.not_after = rcgen::date_time_ymd(2030, 1, 1);
let key_pair = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
let cert = params.self_signed(&key_pair).unwrap();
cert.pem()
}
#[test]
fn parse_cert() {
let pem_data = generate_test_cert();
let info = CertInfo::from_pem(&pem_data).unwrap();
assert!(info.san.contains(&"test.example.com".to_string()));
assert!(!info.spki_der.is_empty());
assert!(info.days_until_expiry() > 0);
}
#[test]
fn expiry_check() {
let pem_data = generate_test_cert();
let info = CertInfo::from_pem(&pem_data).unwrap();
// Cert expires in 2030, so it should not expire within 30 days
assert!(!info.expires_within_days(30));
}
}
+89
View File
@@ -0,0 +1,89 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "certforge", about = "ACME certificate manager with DANE and systemd integration")]
pub struct Cli {
/// Config file path
#[arg(short, long, default_value = "/etc/certforge/config.toml")]
pub config: PathBuf,
/// Increase verbosity (-v, -vv, -vvv)
#[arg(short, long, action = clap::ArgAction::Count)]
pub verbose: u8,
/// Show what would be done without making changes
#[arg(short = 'n', long)]
pub dry_run: bool,
/// State directory
#[arg(long, default_value = "/var/lib/certforge")]
pub state_dir: PathBuf,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
/// Check all certificates and renew those expiring soon
Renew {
/// Only renew the named certificate
#[arg(long)]
name: Option<String>,
/// Force renewal even if not expiring soon
#[arg(long)]
force: bool,
},
/// Show status of all configured certificates
Status {
/// Only show status for the named certificate
#[arg(long)]
name: Option<String>,
},
/// Force-issue a specific certificate
Issue {
/// Certificate name
#[arg(long)]
name: String,
},
/// Force-(re)publish DANE TLSA records
DanePublish {
/// Only publish for the named certificate
#[arg(long)]
name: Option<String>,
},
/// Query and verify published TLSA records against current certs
DaneCheck {
/// Only check the named certificate
#[arg(long)]
name: Option<String>,
},
/// Validate configuration file
ConfigCheck,
/// Print systemd-creds encrypt commands for initial setup
Init,
/// Manage ACME account
Account {
#[command(subcommand)]
action: AccountAction,
},
}
#[derive(Subcommand)]
pub enum AccountAction {
/// Create a new ACME account
Create,
/// Show account information
Show,
/// Deactivate the account
Deactivate,
}
+462
View File
@@ -0,0 +1,462 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
#[derive(Debug, Deserialize)]
pub struct Config {
pub acme: AcmeConfig,
pub dns: DnsConfig,
#[serde(rename = "certificate")]
pub certificates: Vec<CertificateConfig>,
}
#[derive(Debug, Deserialize)]
pub struct AcmeConfig {
#[serde(default = "default_directory_url")]
pub directory_url: String,
/// Full path to encrypted credential file for the ACME account key
pub account_key_credential: Option<PathBuf>,
/// Plain file path for the ACME account key (fallback)
pub account_key_path: Option<PathBuf>,
pub contact: Vec<String>,
}
fn default_directory_url() -> String {
"https://acme-v02.api.letsencrypt.org/directory".to_string()
}
#[derive(Debug, Deserialize)]
pub struct DnsConfig {
pub defaults: DnsServerConfig,
#[serde(default)]
pub zones: HashMap<String, DnsZoneOverride>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DnsServerConfig {
pub server: String,
/// Full path to encrypted credential file for the TSIG key
pub tsig_key_credential: Option<PathBuf>,
/// Plain file path for the TSIG key (fallback)
pub tsig_key_path: Option<PathBuf>,
pub tsig_key_name: String,
#[serde(default = "default_tsig_algorithm")]
pub tsig_algorithm: TsigAlgorithm,
#[serde(default = "default_dns_port")]
pub port: u16,
#[serde(default = "default_dns_protocol")]
pub protocol: DnsProtocol,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DnsZoneOverride {
pub server: Option<String>,
pub tsig_key_credential: Option<PathBuf>,
pub tsig_key_path: Option<PathBuf>,
pub tsig_key_name: Option<String>,
pub tsig_algorithm: Option<TsigAlgorithm>,
pub port: Option<u16>,
pub protocol: Option<DnsProtocol>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum TsigAlgorithm {
HmacSha256,
HmacSha512,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DnsProtocol {
Tcp,
Udp,
}
fn default_tsig_algorithm() -> TsigAlgorithm {
TsigAlgorithm::HmacSha256
}
fn default_dns_port() -> u16 {
53
}
fn default_dns_protocol() -> DnsProtocol {
DnsProtocol::Tcp
}
#[derive(Debug, Deserialize)]
pub struct CertificateConfig {
pub name: String,
pub domains: Vec<String>,
#[serde(default = "default_key_type")]
pub key_type: KeyType,
/// Full path to encrypted credential file for the private key.
/// Services access the same file via LoadCredentialEncrypted= in their units.
pub key_credential: Option<PathBuf>,
/// Plain file path for the private key (fallback for services that can't use credentials)
pub key_path: Option<PathBuf>,
pub cert_path: PathBuf,
#[serde(default = "default_renew_before_days")]
pub renew_before_days: u32,
#[serde(default)]
pub rotate_key: bool,
#[serde(default)]
pub dane: Vec<DaneConfig>,
#[serde(default, rename = "hook")]
pub hooks: Vec<HookConfig>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum KeyType {
EcdsaP256,
EcdsaP384,
Rsa2048,
Rsa4096,
}
fn default_key_type() -> KeyType {
KeyType::EcdsaP256
}
fn default_renew_before_days() -> u32 {
30
}
#[derive(Debug, Deserialize)]
pub struct DaneConfig {
#[serde(default = "default_dane_usage")]
pub usage: DaneUsage,
#[serde(default = "default_dane_selector")]
pub selector: DaneSelector,
#[serde(default = "default_dane_matching")]
pub matching: DaneMatching,
/// TLSA record DNS names (e.g., _25._tcp.mx1.example.com)
pub names: Vec<String>,
#[serde(default = "default_dane_ttl")]
pub ttl: u32,
/// Pre-publish new TLSA before key rotation to avoid DANE breakage
#[serde(default)]
pub pre_publish: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DaneUsage {
/// PKIX-TA (0)
#[serde(rename = "pkix-ta")]
PkixTa,
/// PKIX-EE (1)
#[serde(rename = "pkix-ee")]
PkixEe,
/// DANE-TA (2)
#[serde(rename = "ta")]
Ta,
/// DANE-EE (3)
#[serde(rename = "ee")]
Ee,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DaneSelector {
/// Full certificate (0)
Full,
/// SubjectPublicKeyInfo (1)
Spki,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DaneMatching {
/// Exact match (0)
Full,
/// SHA-256 (1)
Sha256,
/// SHA-512 (2)
Sha512,
}
fn default_dane_usage() -> DaneUsage {
DaneUsage::Ee
}
fn default_dane_selector() -> DaneSelector {
DaneSelector::Spki
}
fn default_dane_matching() -> DaneMatching {
DaneMatching::Sha256
}
fn default_dane_ttl() -> u32 {
300
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum HookConfig {
SystemdReload { unit: String },
SystemdRestart { unit: String },
Command { command: Vec<String> },
}
impl DnsConfig {
/// Resolve DNS server config for a given zone, applying overrides on top of defaults.
pub fn resolve_for_zone(&self, zone: &str) -> DnsServerConfig {
let Some(ovr) = self.zones.get(zone) else {
return self.defaults.clone();
};
DnsServerConfig {
server: ovr.server.clone().unwrap_or_else(|| self.defaults.server.clone()),
tsig_key_credential: ovr
.tsig_key_credential
.clone()
.or_else(|| self.defaults.tsig_key_credential.clone()),
tsig_key_path: ovr
.tsig_key_path
.clone()
.or_else(|| self.defaults.tsig_key_path.clone()),
tsig_key_name: ovr
.tsig_key_name
.clone()
.unwrap_or_else(|| self.defaults.tsig_key_name.clone()),
tsig_algorithm: ovr
.tsig_algorithm
.clone()
.unwrap_or_else(|| self.defaults.tsig_algorithm.clone()),
port: ovr.port.unwrap_or(self.defaults.port),
protocol: ovr.protocol.clone().unwrap_or_else(|| self.defaults.protocol.clone()),
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path).map_err(|e| {
Error::Config(format!("failed to read config file {}: {e}", path.display()))
})?;
let config: Config = toml::from_str(&content)
.map_err(|e| Error::Config(format!("failed to parse config: {e}")))?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<()> {
if self.acme.account_key_credential.is_none() && self.acme.account_key_path.is_none() {
return Err(Error::Config(
"acme: either account_key_credential or account_key_path must be set".into(),
));
}
if self.acme.contact.is_empty() {
return Err(Error::Config("acme: contact must not be empty".into()));
}
if self.dns.defaults.tsig_key_credential.is_none()
&& self.dns.defaults.tsig_key_path.is_none()
{
return Err(Error::Config(
"dns.defaults: either tsig_key_credential or tsig_key_path must be set".into(),
));
}
for cert in &self.certificates {
if cert.name.is_empty() {
return Err(Error::Config("certificate: name must not be empty".into()));
}
if cert.domains.is_empty() {
return Err(Error::Config(format!(
"certificate {}: domains must not be empty",
cert.name
)));
}
if cert.key_credential.is_none() && cert.key_path.is_none() {
return Err(Error::Config(format!(
"certificate {}: either key_credential or key_path must be set",
cert.name
)));
}
for dane in &cert.dane {
if dane.names.is_empty() {
return Err(Error::Config(format!(
"certificate {}: dane block has empty names",
cert.name
)));
}
}
for hook in &cert.hooks {
match hook {
HookConfig::Command { command } if command.is_empty() => {
return Err(Error::Config(format!(
"certificate {}: command hook has empty command",
cert.name
)));
}
_ => {}
}
}
}
Ok(())
}
/// Find a certificate config by name.
#[allow(dead_code)]
pub fn find_certificate(&self, name: &str) -> Option<&CertificateConfig> {
self.certificates.iter().find(|c| c.name == name)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_full_config() {
let toml = r#"
[acme]
directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory"
account_key_credential = "/etc/credstore.encrypted/acme-account-key"
contact = ["mailto:admin@example.com"]
[dns.defaults]
server = "ns1.example.com"
tsig_key_credential = "/etc/credstore.encrypted/dns-tsig-key"
tsig_key_name = "certforge-update."
tsig_algorithm = "hmac-sha256"
port = 53
protocol = "tcp"
[dns.zones."example.org"]
server = "ns2.example.org"
tsig_key_credential = "/etc/credstore.encrypted/dns-tsig-org"
[[certificate]]
name = "mail"
domains = ["mail.example.com", "mail.example.org"]
key_type = "ecdsa-p256"
key_credential = "/etc/credstore.encrypted/mail-tls-key"
cert_path = "/etc/certforge/certs/mail.pem"
renew_before_days = 30
rotate_key = false
[[certificate.dane]]
usage = "ee"
selector = "spki"
matching = "sha256"
names = ["_25._tcp.mx1.example.com", "_25._tcp.mx2.example.com"]
ttl = 300
pre_publish = true
[[certificate.hook]]
type = "systemd-reload"
unit = "postfix.service"
[[certificate.hook]]
type = "command"
command = ["/usr/local/bin/deploy-cert.sh", "--cert", "mail"]
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.certificates.len(), 1);
let cert = &config.certificates[0];
assert_eq!(cert.name, "mail");
assert_eq!(cert.domains, vec!["mail.example.com", "mail.example.org"]);
assert_eq!(cert.key_type, KeyType::EcdsaP256);
assert_eq!(
cert.key_credential.as_deref(),
Some(Path::new("/etc/credstore.encrypted/mail-tls-key"))
);
assert!(!cert.rotate_key);
assert_eq!(cert.dane.len(), 1);
assert_eq!(cert.dane[0].usage, DaneUsage::Ee);
assert_eq!(cert.dane[0].selector, DaneSelector::Spki);
assert_eq!(cert.dane[0].names.len(), 2);
assert!(cert.dane[0].pre_publish);
assert_eq!(cert.hooks.len(), 2);
// Test zone override resolution
let resolved = config.dns.resolve_for_zone("example.org");
assert_eq!(resolved.server, "ns2.example.org");
assert_eq!(
resolved.tsig_key_credential.as_deref(),
Some(Path::new("/etc/credstore.encrypted/dns-tsig-org"))
);
assert_eq!(resolved.tsig_key_name, "certforge-update.");
// Test default zone resolution
let default_resolved = config.dns.resolve_for_zone("example.com");
assert_eq!(default_resolved.server, "ns1.example.com");
}
#[test]
fn parse_minimal_config() {
let toml = r#"
[acme]
account_key_path = "/etc/certforge/account.key"
contact = ["mailto:admin@example.com"]
[dns.defaults]
server = "ns1.example.com"
tsig_key_path = "/etc/certforge/tsig.key"
tsig_key_name = "certforge."
[[certificate]]
name = "web"
domains = ["example.com"]
key_path = "/etc/certforge/keys/web.key"
cert_path = "/etc/certforge/certs/web.pem"
"#;
let config: Config = toml::from_str(toml).unwrap();
config.validate().unwrap();
assert_eq!(config.certificates[0].key_type, KeyType::EcdsaP256);
assert_eq!(config.certificates[0].renew_before_days, 30);
assert!(config.certificates[0].dane.is_empty());
assert!(config.certificates[0].hooks.is_empty());
}
#[test]
fn validation_rejects_missing_key() {
let toml = r#"
[acme]
account_key_path = "/etc/certforge/account.key"
contact = ["mailto:admin@example.com"]
[dns.defaults]
server = "ns1.example.com"
tsig_key_path = "/etc/certforge/tsig.key"
tsig_key_name = "certforge."
[[certificate]]
name = "web"
domains = ["example.com"]
cert_path = "/etc/certforge/certs/web.pem"
"#;
let config: Config = toml::from_str(toml).unwrap();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("key_credential or key_path"));
}
}
+83
View File
@@ -0,0 +1,83 @@
use std::path::Path;
use tokio::process::Command;
use crate::error::{Error, Result};
/// Load a secret from either a systemd encrypted credential file or a plain file.
///
/// If `credential` is set, decrypts it at runtime via `systemd-creds decrypt`.
/// If only `path` is set, reads the file directly.
pub async fn load_secret(credential: Option<&Path>, path: Option<&Path>) -> Result<Vec<u8>> {
if let Some(cred_path) = credential {
decrypt_credential(cred_path).await
} else if let Some(file_path) = path {
std::fs::read(file_path).map_err(|_| Error::CredentialNotFound {
name: file_path.display().to_string(),
path: file_path.to_path_buf(),
})
} else {
Err(Error::Config(
"neither credential nor path configured".into(),
))
}
}
/// Decrypt an encrypted credential file using `systemd-creds decrypt`.
async fn decrypt_credential(path: &Path) -> Result<Vec<u8>> {
let output = Command::new("systemd-creds")
.arg("decrypt")
.arg(path)
.arg("-")
.output()
.await
.map_err(|e| Error::SystemdCreds(format!("failed to run systemd-creds: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::SystemdCreds(format!(
"systemd-creds decrypt {} failed: {stderr}",
path.display()
)));
}
Ok(output.stdout)
}
/// Encrypt data and write it to an encrypted credential file.
///
/// Uses `systemd-creds encrypt --name=<name> - <path>`.
pub async fn encrypt_credential(name: &str, data: &[u8], path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut child = Command::new("systemd-creds")
.arg("encrypt")
.arg(format!("--name={name}"))
.arg("-")
.arg(path)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| Error::SystemdCreds(format!("failed to run systemd-creds: {e}")))?;
use tokio::io::AsyncWriteExt;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(data).await?;
// Drop stdin to close it so systemd-creds can proceed
}
let output = child.wait_with_output().await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::SystemdCreds(format!(
"systemd-creds encrypt to {} failed: {stderr}",
path.display()
)));
}
Ok(())
}
+133
View File
@@ -0,0 +1,133 @@
use hickory_proto::rr::Name;
use crate::certs::CertInfo;
use crate::config::DaneConfig;
use crate::dns::tlsa::TlsaRecord;
use crate::dns::update::DnsUpdater;
use crate::error::{Error, Result};
use crate::keys::CertKeyPair;
/// Compute all TLSA records needed for a certificate's DANE config.
pub fn compute_tlsa_records(
cert: &CertInfo,
dane: &DaneConfig,
) -> Result<Vec<TlsaRecord>> {
let mut records = Vec::new();
let record = TlsaRecord::from_certificate(
&cert.leaf_der,
&cert.spki_der,
&dane.usage,
&dane.selector,
&dane.matching,
)?;
// Same TLSA rdata for all names in this block
for _ in &dane.names {
records.push(record.clone());
}
Ok(records)
}
/// Compute TLSA records from a key pair (for pre-publication before cert issuance).
/// Only valid with SPKI selector.
pub fn compute_tlsa_from_key(
key: &CertKeyPair,
dane: &DaneConfig,
) -> Result<Vec<TlsaRecord>> {
use crate::config::DaneSelector;
if dane.selector != DaneSelector::Spki {
return Err(Error::Certificate(
"DANE pre-publication requires SPKI selector".into(),
));
}
let spki = key.spki_der()?;
let record = TlsaRecord::from_spki(&spki, &dane.usage, &dane.matching)?;
Ok(vec![record; dane.names.len()])
}
/// Publish TLSA records for a certificate via DNS updates.
pub async fn publish_tlsa(
updater: &mut DnsUpdater,
dane: &DaneConfig,
records: &[TlsaRecord],
) -> Result<()> {
// All names in a DANE block share the same TLSA rdata
let record = &records[0];
for name_str in &dane.names {
let name = Name::from_ascii(name_str)
.map_err(|e| Error::DnsUpdate(format!("invalid TLSA name {name_str}: {e}")))?;
let zone = find_zone(&name)?;
updater
.replace_tlsa_records(&zone, &name, &[record.clone()], dane.ttl)
.await?;
}
Ok(())
}
/// Add TLSA records alongside existing ones (for pre-publication).
pub async fn add_tlsa(
updater: &mut DnsUpdater,
dane: &DaneConfig,
record: &TlsaRecord,
) -> Result<()> {
for name_str in &dane.names {
let name = Name::from_ascii(name_str)
.map_err(|e| Error::DnsUpdate(format!("invalid TLSA name {name_str}: {e}")))?;
let zone = find_zone(&name)?;
updater
.add_tlsa_record(&zone, &name, record, dane.ttl)
.await?;
}
Ok(())
}
/// Extract the zone (parent domain) from a TLSA name like _25._tcp.mx1.example.com.
///
/// Strategy: strip the _port._proto prefix, then take the last two labels as zone.
/// This is a heuristic; a proper implementation would query for SOA.
fn find_zone(name: &Name) -> Result<Name> {
// TLSA names are like _25._tcp.host.example.com.
// We need the zone, which is typically example.com.
// Strip underscore-prefixed labels, then take parent as zone.
let labels: Vec<&[u8]> = name.iter().collect();
// Find the first non-underscore label
let mut start = 0;
for (i, label) in labels.iter().enumerate() {
if !label.starts_with(b"_") {
start = i;
break;
}
}
// The zone is everything after the hostname
// e.g., for _25._tcp.mx1.example.com, start=2 (mx1), zone = example.com
if labels.len() > start + 1 {
// Take from start+1 onwards as zone
let zone_labels = &labels[start + 1..];
let zone_str = zone_labels
.iter()
.map(|l| String::from_utf8_lossy(l).to_string())
.collect::<Vec<_>>()
.join(".");
Name::from_ascii(&zone_str)
.map_err(|e| Error::DnsUpdate(format!("invalid zone name: {e}")))
} else {
Err(Error::DnsUpdate(format!(
"cannot determine zone from TLSA name: {name}"
)))
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod tlsa;
pub mod tsig;
pub mod update;
+210
View File
@@ -0,0 +1,210 @@
use sha2::{Digest, Sha256, Sha512};
use crate::config::{DaneMatching, DaneSelector, DaneUsage};
use crate::error::Result;
/// Represents a TLSA record's rdata fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlsaRecord {
pub usage: u8,
pub selector: u8,
pub matching_type: u8,
pub association_data: Vec<u8>,
}
impl TlsaRecord {
/// Compute a TLSA record from a DER-encoded certificate.
pub fn from_certificate(
cert_der: &[u8],
spki_der: &[u8],
usage: &DaneUsage,
selector: &DaneSelector,
matching: &DaneMatching,
) -> Result<Self> {
let data = match selector {
DaneSelector::Full => cert_der.to_vec(),
DaneSelector::Spki => spki_der.to_vec(),
};
let association_data = match matching {
DaneMatching::Full => data,
DaneMatching::Sha256 => {
let mut hasher = Sha256::new();
hasher.update(&data);
hasher.finalize().to_vec()
}
DaneMatching::Sha512 => {
let mut hasher = Sha512::new();
hasher.update(&data);
hasher.finalize().to_vec()
}
};
Ok(Self {
usage: usage.to_u8(),
selector: selector.to_u8(),
matching_type: matching.to_u8(),
association_data,
})
}
/// Compute a TLSA record from a public key's SPKI DER.
/// Only valid with Selector::Spki.
pub fn from_spki(
spki_der: &[u8],
usage: &DaneUsage,
matching: &DaneMatching,
) -> Result<Self> {
let association_data = match matching {
DaneMatching::Full => spki_der.to_vec(),
DaneMatching::Sha256 => {
let mut hasher = Sha256::new();
hasher.update(spki_der);
hasher.finalize().to_vec()
}
DaneMatching::Sha512 => {
let mut hasher = Sha512::new();
hasher.update(spki_der);
hasher.finalize().to_vec()
}
};
Ok(Self {
usage: usage.to_u8(),
selector: DaneSelector::Spki.to_u8(),
matching_type: matching.to_u8(),
association_data,
})
}
/// Format the association data as a hex string (for display / DNS zone format).
pub fn association_data_hex(&self) -> String {
hex::encode(&self.association_data)
}
/// Format as a DNS record data string: "usage selector matching hex"
pub fn to_rdata_string(&self) -> String {
format!(
"{} {} {} {}",
self.usage,
self.selector,
self.matching_type,
self.association_data_hex()
)
}
}
impl DaneUsage {
pub fn to_u8(&self) -> u8 {
match self {
DaneUsage::PkixTa => 0,
DaneUsage::PkixEe => 1,
DaneUsage::Ta => 2,
DaneUsage::Ee => 3,
}
}
}
impl DaneSelector {
pub fn to_u8(&self) -> u8 {
match self {
DaneSelector::Full => 0,
DaneSelector::Spki => 1,
}
}
}
impl DaneMatching {
pub fn to_u8(&self) -> u8 {
match self {
DaneMatching::Full => 0,
DaneMatching::Sha256 => 1,
DaneMatching::Sha512 => 2,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tlsa_from_spki_sha256() {
let fake_spki = b"this is a fake spki for testing";
let record = TlsaRecord::from_spki(
fake_spki,
&DaneUsage::Ee,
&DaneMatching::Sha256,
)
.unwrap();
assert_eq!(record.usage, 3);
assert_eq!(record.selector, 1);
assert_eq!(record.matching_type, 1);
assert_eq!(record.association_data.len(), 32); // SHA-256 output
}
#[test]
fn tlsa_from_spki_sha512() {
let fake_spki = b"this is a fake spki for testing";
let record = TlsaRecord::from_spki(
fake_spki,
&DaneUsage::Ee,
&DaneMatching::Sha512,
)
.unwrap();
assert_eq!(record.association_data.len(), 64); // SHA-512 output
}
#[test]
fn tlsa_rdata_string() {
let record = TlsaRecord {
usage: 3,
selector: 1,
matching_type: 1,
association_data: vec![0xab, 0xcd, 0xef],
};
assert_eq!(record.to_rdata_string(), "3 1 1 abcdef");
}
#[test]
fn tlsa_from_cert_with_spki_selector() {
use crate::keys::CertKeyPair;
use crate::config::KeyType;
let kp = CertKeyPair::generate(&KeyType::EcdsaP256).unwrap();
let spki = kp.spki_der().unwrap();
// Generate a self-signed cert to get DER
let pkcs8_der = rustls_pki_types::PrivatePkcs8KeyDer::from(kp.pkcs8_der.as_slice());
let rcgen_kp = rcgen::KeyPair::from_pkcs8_der_and_sign_algo(
&pkcs8_der,
&rcgen::PKCS_ECDSA_P256_SHA256,
)
.unwrap();
let params =
rcgen::CertificateParams::new(vec!["test.example.com".to_string()]).unwrap();
let cert = params.self_signed(&rcgen_kp).unwrap();
let cert_der = cert.der().to_vec();
let record = TlsaRecord::from_certificate(
&cert_der,
&spki,
&DaneUsage::Ee,
&DaneSelector::Spki,
&DaneMatching::Sha256,
)
.unwrap();
// SPKI-based TLSA from cert should match SPKI-based TLSA from key
let record_from_key = TlsaRecord::from_spki(
&spki,
&DaneUsage::Ee,
&DaneMatching::Sha256,
)
.unwrap();
assert_eq!(record.association_data, record_from_key.association_data);
}
}
+58
View File
@@ -0,0 +1,58 @@
use std::sync::Arc;
use base64::Engine;
use hickory_proto::dnssec::tsig::TSigner;
use hickory_proto::dnssec::rdata::tsig::TsigAlgorithm;
use hickory_proto::op::MessageFinalizer;
use hickory_proto::rr::Name;
use crate::config;
use crate::credentials;
use crate::error::{Error, Result};
/// Load a TSIG signer from the DNS server config.
pub async fn load_tsig_signer(
dns_config: &config::DnsServerConfig,
) -> Result<Arc<dyn MessageFinalizer>> {
let raw_key = credentials::load_secret(
dns_config.tsig_key_credential.as_deref(),
dns_config.tsig_key_path.as_deref(),
)
.await?;
// TSIG keys are typically stored as base64 (matching BIND's key format).
// Try to decode as base64; if that fails, use the raw bytes as-is.
let key_data = decode_tsig_key(&raw_key)?;
let algorithm = match dns_config.tsig_algorithm {
config::TsigAlgorithm::HmacSha256 => TsigAlgorithm::HmacSha256,
config::TsigAlgorithm::HmacSha512 => TsigAlgorithm::HmacSha512,
};
let signer_name = Name::from_ascii(&dns_config.tsig_key_name)
.map_err(|e| Error::DnsUpdate(format!("invalid TSIG key name: {e}")))?;
// Default TSIG fudge is 300 seconds (5 minutes)
let signer = TSigner::new(key_data, algorithm, signer_name, 300)
.map_err(|e| Error::DnsUpdate(format!("failed to create TSIG signer: {e}")))?;
Ok(Arc::new(signer))
}
/// Decode a TSIG key from base64 or return raw bytes.
///
/// Strips whitespace/newlines before decoding. If the data looks like
/// valid base64, decode it; otherwise use as-is (already binary).
fn decode_tsig_key(raw: &[u8]) -> Result<Vec<u8>> {
let trimmed: Vec<u8> = raw.iter().copied().filter(|b| !b.is_ascii_whitespace()).collect();
// Try standard base64 first, then URL-safe
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&trimmed) {
if !decoded.is_empty() {
return Ok(decoded);
}
}
// If it doesn't decode as base64, assume it's already raw binary
Ok(raw.to_vec())
}
+285
View File
@@ -0,0 +1,285 @@
use std::net::{SocketAddr, ToSocketAddrs};
use std::sync::Arc;
use std::time::Duration;
use hickory_client::client::{Client, ClientHandle};
use hickory_proto::op::{MessageFinalizer, ResponseCode};
use hickory_proto::rr::rdata::{TLSA, TXT};
use hickory_proto::rr::rdata::tlsa::{CertUsage, Matching, Selector};
use hickory_proto::rr::{Name, RData, Record, RecordType};
use hickory_proto::tcp::TcpClientStream;
use hickory_proto::xfer::DnsResponse;
use tokio::task::JoinHandle;
use hickory_client::{ClientError, ClientErrorKind};
use hickory_proto::ProtoErrorKind;
use crate::config::{DnsProtocol, DnsServerConfig};
use crate::dns::tlsa::TlsaRecord;
use crate::error::{Error, Result};
/// Produce a clear error message from a hickory ClientError.
fn dns_err(operation: &str, e: ClientError) -> Error {
let detail = match e.kind() {
ClientErrorKind::Proto(proto) => match proto.kind() {
ProtoErrorKind::Busy => "server busy".into(),
ProtoErrorKind::Timeout => "request timed out".into(),
ProtoErrorKind::RequestRefused => {
"update REFUSED by server (check TSIG key and update policy)".into()
}
ProtoErrorKind::NoRecordsFound { response_code, .. } => {
format!("server responded with {response_code}")
}
_ => format!("{proto}"),
},
_ => format!("{e}"),
};
Error::DnsUpdate(format!("{operation}: {detail}"))
}
fn dns_proto_err(operation: &str, e: hickory_proto::ProtoError) -> Error {
dns_err(operation, ClientError::from(e))
}
/// Check the DNS response code from an update operation.
/// Hickory doesn't check response codes for updates, so we must do it ourselves.
fn check_update_response(operation: &str, response: &DnsResponse) -> Result<()> {
let code = response.response_code();
match code {
ResponseCode::NoError => Ok(()),
ResponseCode::Refused => Err(Error::DnsUpdate(format!(
"{operation}: update REFUSED by server (check TSIG key name, secret, and update-policy/allow-update in zone config)"
))),
ResponseCode::NotAuth => Err(Error::DnsUpdate(format!(
"{operation}: server is not authoritative for this zone (NOTAUTH)"
))),
ResponseCode::NXRRSet => Err(Error::DnsUpdate(format!(
"{operation}: prerequisite failed - RRset does not exist (NXRRSET)"
))),
ResponseCode::YXRRSet => Err(Error::DnsUpdate(format!(
"{operation}: prerequisite failed - RRset already exists (YXRRSET)"
))),
ResponseCode::NotZone => Err(Error::DnsUpdate(format!(
"{operation}: update name is not within the zone (NOTZONE)"
))),
_ => Err(Error::DnsUpdate(format!(
"{operation}: server responded with {code}"
))),
}
}
/// DNS update client for RFC 2136 operations.
pub struct DnsUpdater {
client: Client,
_bg: JoinHandle<()>,
}
impl DnsUpdater {
/// Connect to a DNS server with TSIG authentication.
pub async fn connect(
config: &DnsServerConfig,
signer: Arc<dyn MessageFinalizer>,
) -> Result<Self> {
let addr_str = format!("{}:{}", config.server, config.port);
let addr: SocketAddr = addr_str
.to_socket_addrs()
.map_err(|e| Error::DnsUpdate(format!("failed to resolve DNS server {addr_str}: {e}")))?
.next()
.ok_or_else(|| Error::DnsUpdate(format!("DNS server {addr_str} resolved to no addresses")))?;
tracing::debug!(%addr, server = %config.server, "resolved DNS server address");
let timeout = Duration::from_secs(10);
match config.protocol {
DnsProtocol::Tcp => Self::connect_tcp(addr, signer, timeout).await,
DnsProtocol::Udp => {
tracing::warn!("UDP requested but using TCP for DNS updates (RFC 2136 recommendation)");
Self::connect_tcp(addr, signer, timeout).await
}
}
}
async fn connect_tcp(
addr: SocketAddr,
signer: Arc<dyn MessageFinalizer>,
timeout: Duration,
) -> Result<Self> {
let (stream, sender) = TcpClientStream::new::<hickory_proto::runtime::TokioRuntimeProvider>(
addr, None, Some(timeout), Default::default(),
);
let (client, bg) = Client::with_timeout(
stream,
sender,
timeout,
Some(signer),
)
.await
.map_err(|e| dns_proto_err("TCP connection failed", e))?;
let bg_handle = tokio::spawn(async move {
let _ = bg.await;
});
Ok(Self {
client,
_bg: bg_handle,
})
}
/// Add a TXT record (for ACME DNS-01 challenge).
///
/// Uses `append` (not `create`) so it succeeds even if a stale record
/// from a previous failed run is still present.
pub async fn add_txt_record(
&mut self,
zone: &Name,
name: &Name,
value: &str,
ttl: u32,
) -> Result<()> {
let txt = TXT::new(vec![value.to_string()]);
let record = Record::from_rdata(name.clone(), ttl, RData::TXT(txt));
let response = self
.client
.append(record, zone.clone(), false)
.await
.map_err(|e| dns_err("failed to add TXT record", e))?;
check_update_response("failed to add TXT record", &response)?;
tracing::debug!(%name, %value, "added TXT record");
Ok(())
}
/// Delete a specific TXT record (cleanup after ACME challenge).
pub async fn delete_txt_record(
&mut self,
zone: &Name,
name: &Name,
value: &str,
) -> Result<()> {
let txt = TXT::new(vec![value.to_string()]);
let record = Record::from_rdata(name.clone(), 0, RData::TXT(txt));
let response = self
.client
.delete_by_rdata(record, zone.clone())
.await
.map_err(|e| dns_err("failed to delete TXT record", e))?;
check_update_response("failed to delete TXT record", &response)?;
tracing::debug!(%name, "deleted TXT record");
Ok(())
}
/// Add a TLSA record (for DANE pre-publication or initial publish).
pub async fn add_tlsa_record(
&mut self,
zone: &Name,
name: &Name,
tlsa: &TlsaRecord,
ttl: u32,
) -> Result<()> {
let rdata = tlsa_to_rdata(tlsa);
let record = Record::from_rdata(name.clone(), ttl, rdata);
let response = self
.client
.create(record, zone.clone())
.await
.map_err(|e| dns_err("failed to add TLSA record", e))?;
check_update_response("failed to add TLSA record", &response)?;
tracing::debug!(%name, rdata = %tlsa.to_rdata_string(), "added TLSA record");
Ok(())
}
/// Replace all TLSA records at a name (delete all, then add new ones).
pub async fn replace_tlsa_records(
&mut self,
zone: &Name,
name: &Name,
records: &[TlsaRecord],
ttl: u32,
) -> Result<()> {
// First delete all existing TLSA records at this name
let response = self
.client
.delete_rrset(
Record::update0(name.clone(), 0, RecordType::TLSA),
zone.clone(),
)
.await
.map_err(|e| dns_err("failed to delete TLSA RRset", e))?;
check_update_response("failed to delete TLSA RRset", &response)?;
// Then add the new ones
for tlsa in records {
let rdata = tlsa_to_rdata(tlsa);
let record = Record::from_rdata(name.clone(), ttl, rdata);
let response = self
.client
.create(record, zone.clone())
.await
.map_err(|e| dns_err("failed to add TLSA record", e))?;
check_update_response("failed to add TLSA record", &response)?;
}
tracing::debug!(%name, count = records.len(), "replaced TLSA records");
Ok(())
}
/// Delete a specific TLSA record.
#[allow(dead_code)]
pub async fn delete_tlsa_record(
&mut self,
zone: &Name,
name: &Name,
tlsa: &TlsaRecord,
) -> Result<()> {
let rdata = tlsa_to_rdata(tlsa);
let record = Record::from_rdata(name.clone(), 0, rdata);
let response = self
.client
.delete_by_rdata(record, zone.clone())
.await
.map_err(|e| dns_err("failed to delete TLSA record", e))?;
check_update_response("failed to delete TLSA record", &response)?;
tracing::debug!(%name, rdata = %tlsa.to_rdata_string(), "deleted TLSA record");
Ok(())
}
}
/// Convert our TlsaRecord to hickory RData.
fn tlsa_to_rdata(tlsa: &TlsaRecord) -> RData {
let cert_usage = match tlsa.usage {
0 => CertUsage::PkixTa,
1 => CertUsage::PkixEe,
2 => CertUsage::DaneTa,
3 => CertUsage::DaneEe,
n => CertUsage::Unassigned(n),
};
let selector = match tlsa.selector {
0 => Selector::Full,
1 => Selector::Spki,
n => Selector::Unassigned(n),
};
let matching = match tlsa.matching_type {
0 => Matching::Raw,
1 => Matching::Sha256,
2 => Matching::Sha512,
n => Matching::Unassigned(n),
};
RData::TLSA(TLSA::new(
cert_usage,
selector,
matching,
tlsa.association_data.clone(),
))
}
+39
View File
@@ -0,0 +1,39 @@
use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("ACME error: {0}")]
Acme(#[from] instant_acme::Error),
#[error("DNS update failed: {0}")]
DnsUpdate(String),
#[error("certificate error: {0}")]
Certificate(String),
#[error("key error: {0}")]
Key(String),
#[error("config error: {0}")]
Config(String),
#[error("credential {name} not found at {path}")]
CredentialNotFound { name: String, path: PathBuf },
#[error("systemd-creds failed: {0}")]
SystemdCreds(String),
#[error("hook {hook} failed: {message}")]
Hook { hook: String, message: String },
#[error("state error: {0}")]
State(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
+117
View File
@@ -0,0 +1,117 @@
use crate::config::HookConfig;
use crate::error::{Error, Result};
/// Execute post-renewal hooks.
///
/// Hooks run sequentially. A hook failure is logged but does not prevent
/// subsequent hooks from running.
pub async fn run_hooks(hooks: &[HookConfig], dry_run: bool) -> Vec<Result<()>> {
let mut results = Vec::new();
for hook in hooks {
let result = run_hook(hook, dry_run).await;
if let Err(ref e) = result {
tracing::error!(%e, "hook failed");
}
results.push(result);
}
results
}
async fn run_hook(hook: &HookConfig, dry_run: bool) -> Result<()> {
match hook {
HookConfig::SystemdReload { unit } => {
tracing::info!(%unit, "reloading systemd unit");
if dry_run {
tracing::info!(%unit, "dry-run: would reload unit");
return Ok(());
}
systemd_unit_action(unit, "reload").await
}
HookConfig::SystemdRestart { unit } => {
tracing::info!(%unit, "restarting systemd unit");
if dry_run {
tracing::info!(%unit, "dry-run: would restart unit");
return Ok(());
}
systemd_unit_action(unit, "restart").await
}
HookConfig::Command { command } => {
let cmd_str = command.join(" ");
tracing::info!(command = %cmd_str, "running command hook");
if dry_run {
tracing::info!(command = %cmd_str, "dry-run: would run command");
return Ok(());
}
run_command(command).await
}
}
}
/// Reload or restart a systemd unit via D-Bus.
async fn systemd_unit_action(unit: &str, action: &str) -> Result<()> {
let connection = zbus::Connection::system()
.await
.map_err(|e| Error::Hook {
hook: format!("systemd-{action}({unit})"),
message: format!("failed to connect to system bus: {e}"),
})?;
let proxy: zbus::Proxy<'_> = zbus::proxy::Builder::new(&connection)
.interface("org.freedesktop.systemd1.Manager")
.expect("valid interface")
.path("/org/freedesktop/systemd1")
.expect("valid path")
.destination("org.freedesktop.systemd1")
.expect("valid destination")
.build()
.await
.map_err(|e| Error::Hook {
hook: format!("systemd-{action}({unit})"),
message: format!("failed to create proxy: {e}"),
})?;
let method = match action {
"reload" => "ReloadUnit",
"restart" => "RestartUnit",
_ => unreachable!(),
};
let _: zbus::zvariant::OwnedObjectPath = proxy
.call(method, &(unit, "replace"))
.await
.map_err(|e| Error::Hook {
hook: format!("systemd-{action}({unit})"),
message: format!("{method} failed: {e}"),
})?;
tracing::info!(%unit, %action, "systemd unit action completed");
Ok(())
}
async fn run_command(command: &[String]) -> Result<()> {
let (program, args) = command.split_first().ok_or_else(|| Error::Hook {
hook: "command".into(),
message: "empty command".into(),
})?;
let output = tokio::process::Command::new(program)
.args(args)
.output()
.await
.map_err(|e| Error::Hook {
hook: format!("command({})", command.join(" ")),
message: format!("failed to execute: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Hook {
hook: format!("command({})", command.join(" ")),
message: format!("exited with {}: {stderr}", output.status),
});
}
Ok(())
}
+222
View File
@@ -0,0 +1,222 @@
use ring::rand::SystemRandom;
use ring::signature::{self, EcdsaKeyPair, KeyPair as _};
use crate::config::KeyType;
use crate::credentials;
use crate::error::{Error, Result};
use std::path::Path;
/// A generated or loaded key pair with its type metadata.
pub struct CertKeyPair {
pub key_type: KeyType,
/// PKCS#8 v1 DER-encoded private key
pub pkcs8_der: Vec<u8>,
}
impl CertKeyPair {
/// Generate a new key pair of the specified type.
pub fn generate(key_type: &KeyType) -> Result<Self> {
let rng = SystemRandom::new();
let pkcs8_der = match key_type {
KeyType::EcdsaP256 => {
EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_ASN1_SIGNING, &rng)
.map_err(|e| Error::Key(format!("failed to generate ECDSA P-256 key: {e}")))?
.as_ref()
.to_vec()
}
KeyType::EcdsaP384 => {
EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P384_SHA384_ASN1_SIGNING, &rng)
.map_err(|e| Error::Key(format!("failed to generate ECDSA P-384 key: {e}")))?
.as_ref()
.to_vec()
}
KeyType::Rsa2048 | KeyType::Rsa4096 => {
return Err(Error::Key(
"RSA key generation not yet supported; use ecdsa-p256 or ecdsa-p384".into(),
));
}
};
Ok(Self {
key_type: key_type.clone(),
pkcs8_der,
})
}
/// Load a key pair from a credential or file path.
pub async fn load(
credential: Option<&Path>,
path: Option<&Path>,
key_type: &KeyType,
) -> Result<Self> {
let pem_or_der = credentials::load_secret(credential, path).await?;
Self::from_pem_or_der(&pem_or_der, key_type)
}
/// Parse a key from PEM or raw DER bytes.
pub fn from_pem_or_der(data: &[u8], key_type: &KeyType) -> Result<Self> {
// Try PEM first
let der = if let Ok(pem) = pem::parse(data) {
pem.contents().to_vec()
} else {
data.to_vec()
};
// Validate the key can be parsed
match key_type {
KeyType::EcdsaP256 => {
EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&der,
&SystemRandom::new(),
)
.map_err(|e| Error::Key(format!("invalid ECDSA P-256 key: {e}")))?;
}
KeyType::EcdsaP384 => {
EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P384_SHA384_ASN1_SIGNING,
&der,
&SystemRandom::new(),
)
.map_err(|e| Error::Key(format!("invalid ECDSA P-384 key: {e}")))?;
}
KeyType::Rsa2048 | KeyType::Rsa4096 => {
return Err(Error::Key("RSA keys not yet supported".into()));
}
}
Ok(Self {
key_type: key_type.clone(),
pkcs8_der: der,
})
}
/// Encode the private key as PEM.
pub fn to_pem(&self) -> String {
pem::encode(&pem::Pem::new("PRIVATE KEY", self.pkcs8_der.clone()))
}
/// Extract the SubjectPublicKeyInfo (SPKI) in DER format.
pub fn spki_der(&self) -> Result<Vec<u8>> {
match self.key_type {
KeyType::EcdsaP256 => {
let kp = EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
&self.pkcs8_der,
&SystemRandom::new(),
)
.map_err(|e| Error::Key(format!("failed to parse key for SPKI: {e}")))?;
// ring's public_key() returns the raw public key bytes.
// We need to wrap it in SPKI ASN.1 structure.
Ok(wrap_ec_spki(
kp.public_key().as_ref(),
EC_P256_OID,
))
}
KeyType::EcdsaP384 => {
let kp = EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P384_SHA384_ASN1_SIGNING,
&self.pkcs8_der,
&SystemRandom::new(),
)
.map_err(|e| Error::Key(format!("failed to parse key for SPKI: {e}")))?;
Ok(wrap_ec_spki(
kp.public_key().as_ref(),
EC_P384_OID,
))
}
KeyType::Rsa2048 | KeyType::Rsa4096 => {
Err(Error::Key("RSA SPKI extraction not yet supported".into()))
}
}
}
}
// OID for id-ecPublicKey (1.2.840.10045.2.1)
const EC_PUBLIC_KEY_OID: &[u8] = &[0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01];
// OID for secp256r1 (1.2.840.10045.3.1.7)
const EC_P256_OID: &[u8] = &[0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07];
// OID for secp384r1 (1.3.132.0.34)
const EC_P384_OID: &[u8] = &[0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22];
/// Wrap an EC public key in a SubjectPublicKeyInfo ASN.1 structure.
///
/// SPKI = SEQUENCE {
/// algorithm AlgorithmIdentifier = SEQUENCE { id-ecPublicKey, namedCurve },
/// subjectPublicKey BIT STRING
/// }
fn wrap_ec_spki(public_key_bytes: &[u8], curve_oid: &[u8]) -> Vec<u8> {
// AlgorithmIdentifier = SEQUENCE { ecPublicKey OID, curve OID }
let alg_id_content_len = EC_PUBLIC_KEY_OID.len() + curve_oid.len();
let mut alg_id = vec![0x30]; // SEQUENCE
encode_asn1_length(alg_id_content_len, &mut alg_id);
alg_id.extend_from_slice(EC_PUBLIC_KEY_OID);
alg_id.extend_from_slice(curve_oid);
// BIT STRING wrapping the public key
// BIT STRING = 0x03 | length | 0x00 (no unused bits) | public_key_bytes
let bit_string_content_len = 1 + public_key_bytes.len();
let mut bit_string = vec![0x03];
encode_asn1_length(bit_string_content_len, &mut bit_string);
bit_string.push(0x00); // no unused bits
bit_string.extend_from_slice(public_key_bytes);
// SPKI = SEQUENCE { alg_id, bit_string }
let spki_content_len = alg_id.len() + bit_string.len();
let mut spki = vec![0x30]; // SEQUENCE
encode_asn1_length(spki_content_len, &mut spki);
spki.extend_from_slice(&alg_id);
spki.extend_from_slice(&bit_string);
spki
}
fn encode_asn1_length(len: usize, buf: &mut Vec<u8>) {
if len < 0x80 {
buf.push(len as u8);
} else if len < 0x100 {
buf.push(0x81);
buf.push(len as u8);
} else {
buf.push(0x82);
buf.push((len >> 8) as u8);
buf.push(len as u8);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_ecdsa_p256() {
let kp = CertKeyPair::generate(&KeyType::EcdsaP256).unwrap();
assert!(!kp.pkcs8_der.is_empty());
let pem = kp.to_pem();
assert!(pem.starts_with("-----BEGIN PRIVATE KEY-----"));
let spki = kp.spki_der().unwrap();
assert!(!spki.is_empty());
// SPKI should start with SEQUENCE tag
assert_eq!(spki[0], 0x30);
}
#[test]
fn generate_ecdsa_p384() {
let kp = CertKeyPair::generate(&KeyType::EcdsaP384).unwrap();
assert!(!kp.pkcs8_der.is_empty());
let spki = kp.spki_der().unwrap();
assert!(!spki.is_empty());
}
#[test]
fn roundtrip_pem() {
let kp = CertKeyPair::generate(&KeyType::EcdsaP256).unwrap();
let pem_str = kp.to_pem();
let kp2 = CertKeyPair::from_pem_or_der(pem_str.as_bytes(), &KeyType::EcdsaP256).unwrap();
assert_eq!(kp.pkcs8_der, kp2.pkcs8_der);
}
}
+285
View File
@@ -0,0 +1,285 @@
mod acme;
mod certs;
mod cli;
mod config;
mod credentials;
mod dane;
mod dns;
mod error;
mod hooks;
mod keys;
mod renew;
mod state;
use clap::Parser;
use tracing_subscriber::EnvFilter;
use cli::{AccountAction, Cli, Command};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let filter = match cli.verbose {
0 => "certforge=info",
1 => "certforge=debug",
2 => "certforge=trace",
_ => "trace",
};
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)),
)
.init();
match cli.command {
Command::Renew { name, force } => {
let config = config::Config::load(&cli.config)?;
renew::run(&config, &cli.state_dir, name.as_deref(), force, cli.dry_run).await?;
}
Command::Status { name } => {
let config = config::Config::load(&cli.config)?;
print_status(&config, name.as_deref());
}
Command::Issue { name } => {
let config = config::Config::load(&cli.config)?;
renew::run(&config, &cli.state_dir, Some(&name), true, cli.dry_run).await?;
}
Command::DanePublish { name } => {
let config = config::Config::load(&cli.config)?;
dane_publish(&config, name.as_deref(), cli.dry_run).await?;
}
Command::DaneCheck { name } => {
let config = config::Config::load(&cli.config)?;
dane_check(&config, name.as_deref()).await?;
}
Command::ConfigCheck => {
let config = config::Config::load(&cli.config)?;
println!(
"Configuration is valid. {} certificate(s) configured.",
config.certificates.len()
);
for cert in &config.certificates {
println!(
" - {}: {} domain(s), {} DANE block(s), {} hook(s)",
cert.name,
cert.domains.len(),
cert.dane.len(),
cert.hooks.len()
);
}
}
Command::Init => {
let config = config::Config::load(&cli.config)?;
print_init_commands(&config);
}
Command::Account { action } => {
let config = config::Config::load(&cli.config)?;
match action {
AccountAction::Create => {
let client = acme::AcmeClient::new(&config.acme).await?;
println!("Account ID: {}", client.account().id());
}
AccountAction::Show => {
let client = acme::AcmeClient::new(&config.acme).await?;
println!("Account ID: {}", client.account().id());
}
AccountAction::Deactivate => {
tracing::warn!("account deactivation not yet implemented");
}
}
}
}
Ok(())
}
fn print_status(config: &config::Config, name_filter: Option<&str>) {
for cert_config in &config.certificates {
if let Some(filter) = name_filter {
if cert_config.name != filter {
continue;
}
}
println!("Certificate: {}", cert_config.name);
println!(" Domains: {}", cert_config.domains.join(", "));
println!(" Key type: {:?}", cert_config.key_type);
println!(" Rotate key: {}", cert_config.rotate_key);
println!(" Cert path: {}", cert_config.cert_path.display());
match certs::CertInfo::load(&cert_config.cert_path) {
Ok(info) => {
let days = info.days_until_expiry();
println!(" Expires: {} ({days} days)", info.not_after.format("%Y-%m-%d %H:%M UTC"));
if info.expires_within_days(cert_config.renew_before_days) {
println!(" Status: NEEDS RENEWAL");
} else {
println!(" Status: OK");
}
if !info.san.is_empty() {
println!(" SANs: {}", info.san.join(", "));
}
}
Err(_) => {
println!(" Status: NOT ISSUED");
}
}
for (i, dane) in cert_config.dane.iter().enumerate() {
println!(" DANE block {}: {:?}/{:?}/{:?}", i, dane.usage, dane.selector, dane.matching);
println!(" Names: {}", dane.names.join(", "));
println!(" TTL: {}, Pre-publish: {}", dane.ttl, dane.pre_publish);
}
println!();
}
}
async fn dane_publish(
config: &config::Config,
name_filter: Option<&str>,
dry_run: bool,
) -> anyhow::Result<()> {
for cert_config in &config.certificates {
if let Some(filter) = name_filter {
if cert_config.name != filter {
continue;
}
}
if cert_config.dane.is_empty() {
continue;
}
let cert_info = certs::CertInfo::load(&cert_config.cert_path)?;
for dane_config in &cert_config.dane {
let records = dane::compute_tlsa_records(&cert_info, dane_config)?;
if dry_run {
for (name, record) in dane_config.names.iter().zip(records.iter()) {
println!("{name}. IN TLSA {}", record.to_rdata_string());
}
continue;
}
let zone_hint = dane_config.names.first().unwrap();
let parts: Vec<&str> = zone_hint.split('.').filter(|p| !p.starts_with('_')).collect();
let zone_str = if parts.len() >= 2 {
parts[parts.len() - 2..].join(".")
} else {
zone_hint.to_string()
};
let dns_config = config.dns.resolve_for_zone(&zone_str);
let signer = dns::tsig::load_tsig_signer(&dns_config).await?;
let mut updater = dns::update::DnsUpdater::connect(&dns_config, signer).await?;
dane::publish_tlsa(&mut updater, dane_config, &records).await?;
}
tracing::info!(name = %cert_config.name, "DANE TLSA records published");
}
Ok(())
}
async fn dane_check(
config: &config::Config,
name_filter: Option<&str>,
) -> anyhow::Result<()> {
for cert_config in &config.certificates {
if let Some(filter) = name_filter {
if cert_config.name != filter {
continue;
}
}
if cert_config.dane.is_empty() {
continue;
}
println!("Certificate: {}", cert_config.name);
let cert_result = certs::CertInfo::load(&cert_config.cert_path);
for dane_config in &cert_config.dane {
match &cert_result {
Ok(cert_info) => {
let records = dane::compute_tlsa_records(cert_info, dane_config)?;
let record = &records[0];
for name in &dane_config.names {
println!(" {name}: expected TLSA {}", record.to_rdata_string());
// Actual DNS query verification would go here
// For now, just show what we expect
}
}
Err(_) => {
println!(" Certificate not found, cannot compute expected TLSA records");
}
}
}
println!();
}
Ok(())
}
fn print_init_commands(config: &config::Config) {
println!("# Run these commands to set up encrypted credentials.");
println!("# Pipe the secret data into each command via stdin.\n");
if let Some(path) = &config.acme.account_key_credential {
println!(
"# ACME account key (will be auto-generated if not present)\nsystemd-creds encrypt --name=acme-account-key - {}\n",
path.display()
);
}
if let Some(path) = &config.dns.defaults.tsig_key_credential {
println!(
"# DNS TSIG key (default)\nsystemd-creds encrypt --name={} - {}\n",
config.dns.defaults.tsig_key_name,
path.display()
);
}
for (zone, ovr) in &config.dns.zones {
if let Some(path) = &ovr.tsig_key_credential {
let name = ovr
.tsig_key_name
.as_deref()
.unwrap_or(&config.dns.defaults.tsig_key_name);
println!(
"# DNS TSIG key for zone {zone}\nsystemd-creds encrypt --name={name} - {}\n",
path.display()
);
}
}
for cert in &config.certificates {
if let Some(path) = &cert.key_credential {
println!(
"# TLS private key for certificate '{}'\nsystemd-creds encrypt --name={}-tls-key - {}\n",
cert.name,
cert.name,
path.display()
);
}
}
println!("# Example systemd unit LoadCredentialEncrypted directives:");
for cert in &config.certificates {
if let Some(path) = &cert.key_credential {
println!("# LoadCredentialEncrypted={}-tls-key:{}", cert.name, path.display());
}
}
}
+407
View File
@@ -0,0 +1,407 @@
use std::path::Path;
use hickory_proto::rr::Name;
use crate::acme::{AcmeClient, DnsChallenge};
use crate::certs::CertInfo;
use crate::config::{CertificateConfig, Config, DaneSelector};
use crate::credentials;
use crate::dane;
use crate::dns::tsig;
use crate::dns::update::DnsUpdater;
use crate::error::{Error, Result};
use crate::hooks;
use crate::keys::CertKeyPair;
use crate::state::{PendingRotation, State};
/// Run the renewal process for all certificates (or a specific one).
pub async fn run(
config: &Config,
state_dir: &Path,
name_filter: Option<&str>,
force: bool,
dry_run: bool,
) -> Result<()> {
let mut state = State::load(state_dir)?;
let mut state_dirty = false;
let acme = AcmeClient::new(&config.acme).await?;
for cert_config in &config.certificates {
if let Some(filter) = name_filter {
if cert_config.name != filter {
continue;
}
}
match renew_certificate(config, &acme, cert_config, &mut state, state_dir, force, dry_run)
.await
{
Ok(renewed) => {
if renewed {
tracing::info!(name = %cert_config.name, "certificate renewed successfully");
state_dirty = true;
}
// "does not need renewal" is logged inside renew_certificate with days_remaining
}
Err(e) => {
tracing::error!(name = %cert_config.name, error = %e, "certificate renewal failed");
}
}
}
if state_dirty {
state.save(state_dir)?;
}
Ok(())
}
async fn renew_certificate(
config: &Config,
acme: &AcmeClient,
cert_config: &CertificateConfig,
state: &mut State,
state_dir: &Path,
force: bool,
dry_run: bool,
) -> Result<bool> {
// Check if there's a pending key rotation to complete
if let Some(pending) = state.pending_rotations.get(&cert_config.name) {
if pending.ttl_expired() {
tracing::info!(
name = %cert_config.name,
"pending key rotation TTL expired, completing renewal"
);
// Continue with renewal using the pending key
} else {
tracing::info!(
name = %cert_config.name,
"pending key rotation, waiting for TTL expiry"
);
return Ok(false);
}
}
// Check if existing certificate needs renewal
if !force {
if let Ok(cert) = CertInfo::load(&cert_config.cert_path) {
let days = cert.days_until_expiry();
if !cert.expires_within_days(cert_config.renew_before_days) {
tracing::info!(
name = %cert_config.name,
days_remaining = days,
"certificate does not need renewal"
);
return Ok(false);
}
tracing::info!(
name = %cert_config.name,
days_remaining = days,
"certificate expiring soon, renewing"
);
}
// If cert doesn't exist or can't be parsed, proceed with issuance
}
// Determine key pair
let key = if let Some(pending) = state.pending_rotations.remove(&cert_config.name) {
// Use the pending key from pre-publication
let key_data = std::fs::read(&pending.pending_key_path)
.map_err(|e| Error::Key(format!("failed to read pending key: {e}")))?;
CertKeyPair::from_pem_or_der(&key_data, &cert_config.key_type)?
} else if cert_config.rotate_key {
// Check if DANE pre-publication is needed
let needs_pre_publish = cert_config
.dane
.iter()
.any(|d| d.pre_publish && d.selector == DaneSelector::Spki);
let new_key = CertKeyPair::generate(&cert_config.key_type)?;
if needs_pre_publish {
// Pre-publish TLSA with new key, defer renewal
tracing::info!(name = %cert_config.name, "pre-publishing TLSA for key rotation");
if !dry_run {
// Save pending key
let pending_dir = state_dir.join("pending");
std::fs::create_dir_all(&pending_dir)?;
let pending_key_path = pending_dir.join(format!("{}.key", cert_config.name));
std::fs::write(&pending_key_path, new_key.to_pem())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&pending_key_path,
std::fs::Permissions::from_mode(0o600),
)?;
}
// Publish new TLSA alongside old
let max_ttl = pre_publish_dane(config, cert_config, &new_key, dry_run).await?;
state.pending_rotations.insert(
cert_config.name.clone(),
PendingRotation {
pending_key_path,
published_at: chrono::Utc::now(),
old_ttl: max_ttl,
},
);
state.save(state_dir)?;
}
tracing::info!(
name = %cert_config.name,
"TLSA pre-published, renewal deferred to next run"
);
return Ok(false);
}
new_key
} else {
// Load existing key or generate if first time
match CertKeyPair::load(
cert_config.key_credential.as_deref(),
cert_config.key_path.as_deref(),
&cert_config.key_type,
)
.await
{
Ok(key) => key,
Err(_) => {
tracing::info!(name = %cert_config.name, "generating new key pair");
CertKeyPair::generate(&cert_config.key_type)?
}
}
};
if dry_run {
tracing::info!(name = %cert_config.name, "dry-run: would issue certificate");
return Ok(true);
}
// Generate CSR
tracing::debug!(
name = %cert_config.name,
domains = ?cert_config.domains,
key_type = ?cert_config.key_type,
"generating CSR"
);
let pkcs8_der = rustls_pki_types::PrivatePkcs8KeyDer::from(key.pkcs8_der.as_slice());
let rcgen_alg = match cert_config.key_type {
crate::config::KeyType::EcdsaP256 => &rcgen::PKCS_ECDSA_P256_SHA256,
crate::config::KeyType::EcdsaP384 => &rcgen::PKCS_ECDSA_P384_SHA384,
_ => return Err(Error::Key("unsupported key type for CSR generation".into())),
};
let rcgen_kp = rcgen::KeyPair::from_pkcs8_der_and_sign_algo(&pkcs8_der, rcgen_alg)
.map_err(|e| Error::Key(format!("failed to load key for CSR: {e}")))?;
let mut params =
rcgen::CertificateParams::new(cert_config.domains.clone())
.map_err(|e| Error::Certificate(format!("invalid CSR params: {e}")))?;
// Clear the default distinguished name - ACME uses SANs only,
// and the default CN "rcgen self signed cert" causes rejection.
params.distinguished_name = rcgen::DistinguishedName::new();
let csr = params
.serialize_request(&rcgen_kp)
.map_err(|e| Error::Certificate(format!("failed to generate CSR: {e}")))?;
// ACME order
tracing::debug!(name = %cert_config.name, domains = ?cert_config.domains, "creating ACME order");
let (mut order, _) = acme.create_order(&cert_config.domains).await?;
// Collect DNS-01 challenge info
let challenges = AcmeClient::collect_challenges(&mut order).await?;
// Publish DNS-01 challenge TXT records
for challenge in &challenges {
tracing::debug!(
domain = %challenge.domain,
txt_name = %challenge.txt_name,
txt_value = %challenge.txt_value,
"publishing DNS-01 challenge TXT record"
);
publish_challenge_txt(config, challenge).await?;
}
// Run the ACME order flow, ensuring TXT cleanup happens even on failure
let order_result = async {
// Tell ACME server challenges are ready
tracing::debug!(name = %cert_config.name, "setting challenges ready");
AcmeClient::set_challenges_ready(&mut order).await?;
// Finalize and download
tracing::debug!(name = %cert_config.name, "finalizing order");
AcmeClient::finalize_order(&mut order, csr.der()).await?;
tracing::debug!(name = %cert_config.name, "downloading certificate");
AcmeClient::download_certificate(&mut order).await
}
.await;
// Always clean up challenge TXT records
for challenge in &challenges {
if let Err(e) = cleanup_challenge_txt(config, challenge).await {
tracing::warn!(
domain = %challenge.domain,
error = %e,
"failed to clean up challenge TXT record"
);
}
}
let cert_pem = order_result?;
// Save certificate
if let Some(parent) = cert_config.cert_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&cert_config.cert_path, &cert_pem)?;
tracing::info!(path = %cert_config.cert_path.display(), "saved certificate");
// Save key
save_key(&key, cert_config).await?;
// Publish DANE TLSA records
if !cert_config.dane.is_empty() {
let cert_info = CertInfo::from_pem(&cert_pem)?;
for dane_config in &cert_config.dane {
let records = dane::compute_tlsa_records(&cert_info, dane_config)?;
let dns_config = config.dns.resolve_for_zone(&extract_zone_from_tlsa_name(
dane_config.names.first().unwrap(),
));
let signer = tsig::load_tsig_signer(&dns_config).await?;
let mut updater = DnsUpdater::connect(&dns_config, signer).await?;
dane::publish_tlsa(&mut updater, dane_config, &records).await?;
}
}
// Run hooks
let hook_results = hooks::run_hooks(&cert_config.hooks, false).await;
for result in &hook_results {
if let Err(e) = result {
tracing::warn!(error = %e, "hook failed (non-fatal)");
}
}
Ok(true)
}
async fn save_key(key: &CertKeyPair, cert_config: &CertificateConfig) -> Result<()> {
let pem = key.to_pem();
if let Some(cred_path) = &cert_config.key_credential {
let cred_name = format!("{}-tls-key", cert_config.name);
credentials::encrypt_credential(&cred_name, pem.as_bytes(), cred_path).await?;
tracing::info!(path = %cred_path.display(), "saved private key (encrypted)");
} else if let Some(key_path) = &cert_config.key_path {
if let Some(parent) = key_path.parent() {
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");
}
Ok(())
}
async fn publish_challenge_txt(config: &Config, challenge: &DnsChallenge) -> Result<()> {
let zone_str = extract_zone_from_domain(&challenge.domain);
let dns_config = config.dns.resolve_for_zone(&zone_str);
tracing::debug!(
domain = %challenge.domain,
zone = %zone_str,
server = %dns_config.server,
txt_name = %challenge.txt_name,
"publishing challenge TXT via RFC 2136"
);
let signer = tsig::load_tsig_signer(&dns_config).await?;
let mut updater = DnsUpdater::connect(&dns_config, signer).await?;
let name = Name::from_ascii(&challenge.txt_name)
.map_err(|e| Error::DnsUpdate(format!("invalid TXT name: {e}")))?;
let zone = Name::from_ascii(&zone_str)
.map_err(|e| Error::DnsUpdate(format!("invalid zone: {e}")))?;
updater.add_txt_record(&zone, &name, &challenge.txt_value, 60).await
}
async fn cleanup_challenge_txt(config: &Config, challenge: &DnsChallenge) -> Result<()> {
let zone_str = extract_zone_from_domain(&challenge.domain);
let dns_config = config.dns.resolve_for_zone(&zone_str);
let signer = tsig::load_tsig_signer(&dns_config).await?;
let mut updater = DnsUpdater::connect(&dns_config, signer).await?;
let name = Name::from_ascii(&challenge.txt_name)
.map_err(|e| Error::DnsUpdate(format!("invalid TXT name: {e}")))?;
let zone = Name::from_ascii(&zone_str)
.map_err(|e| Error::DnsUpdate(format!("invalid zone: {e}")))?;
updater.delete_txt_record(&zone, &name, &challenge.txt_value).await
}
async fn pre_publish_dane(
config: &Config,
cert_config: &CertificateConfig,
new_key: &CertKeyPair,
dry_run: bool,
) -> Result<u32> {
let mut max_ttl = 0;
for dane_config in &cert_config.dane {
if !dane_config.pre_publish || dane_config.selector != DaneSelector::Spki {
continue;
}
max_ttl = max_ttl.max(dane_config.ttl);
if dry_run {
tracing::info!("dry-run: would pre-publish TLSA for {:?}", dane_config.names);
continue;
}
let records = dane::compute_tlsa_from_key(new_key, dane_config)?;
let record = &records[0];
let dns_config = config.dns.resolve_for_zone(&extract_zone_from_tlsa_name(
dane_config.names.first().unwrap(),
));
let signer = tsig::load_tsig_signer(&dns_config).await?;
let mut updater = DnsUpdater::connect(&dns_config, signer).await?;
dane::add_tlsa(&mut updater, dane_config, record).await?;
}
Ok(max_ttl)
}
/// Extract zone from a domain (heuristic: last two labels).
fn extract_zone_from_domain(domain: &str) -> String {
let parts: Vec<&str> = domain.split('.').collect();
if parts.len() >= 2 {
parts[parts.len() - 2..].join(".")
} else {
domain.to_string()
}
}
/// Extract zone from a TLSA name like _25._tcp.mx1.example.com.
fn extract_zone_from_tlsa_name(name: &str) -> String {
let parts: Vec<&str> = name.split('.').collect();
// Skip underscore-prefixed parts, then skip hostname, take zone
let non_underscore: Vec<&&str> = parts.iter().filter(|p| !p.starts_with('_')).collect();
if non_underscore.len() >= 2 {
non_underscore[non_underscore.len() - 2..].iter().map(|s| **s).collect::<Vec<&str>>().join(".")
} else {
name.to_string()
}
}
+60
View File
@@ -0,0 +1,60 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
/// Persistent state for tracking pre-publication and pending operations.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct State {
/// Pending key rotations keyed by certificate name.
#[serde(default)]
pub pending_rotations: HashMap<String, PendingRotation>,
}
/// State for a pending key rotation with DANE pre-publication.
#[derive(Debug, Serialize, Deserialize)]
pub struct PendingRotation {
/// Path to the encrypted pending key
pub pending_key_path: PathBuf,
/// When the new TLSA was published
pub published_at: DateTime<Utc>,
/// The old TLSA TTL in seconds (we must wait this long)
pub old_ttl: u32,
}
impl PendingRotation {
/// Check if enough time has elapsed for the old TLSA TTL to expire.
pub fn ttl_expired(&self) -> bool {
let elapsed = Utc::now() - self.published_at;
elapsed.num_seconds() >= self.old_ttl as i64
}
}
impl State {
/// Load state from the state directory.
pub fn load(state_dir: &Path) -> Result<Self> {
let path = state_dir.join("state.json");
if !path.exists() {
return Ok(Self::default());
}
let data = std::fs::read_to_string(&path)
.map_err(|e| Error::State(format!("failed to read state file: {e}")))?;
let state: State = serde_json::from_str(&data)
.map_err(|e| Error::State(format!("failed to parse state file: {e}")))?;
Ok(state)
}
/// Save state to the state directory.
pub fn save(&self, state_dir: &Path) -> Result<()> {
std::fs::create_dir_all(state_dir)?;
let path = state_dir.join("state.json");
let data = serde_json::to_string_pretty(self)
.map_err(|e| Error::State(format!("failed to serialize state: {e}")))?;
std::fs::write(&path, data)?;
Ok(())
}
}