mirror of
https://github.com/rkivalin/certforge.git
synced 2026-10-13 00:00:42 +00:00
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:
+222
@@ -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
@@ -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
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod tlsa;
|
||||
pub mod tsig;
|
||||
pub mod update;
|
||||
+210
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user