diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a20f25..9a8f18b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - The account key type and signature algorithm can now be specified in the configuration. +- The delay to renew a certificate before its expiration date can be specified in the configuration using the `renew_delay` parameter at either the certificate, endpoint and global level. ### Fixed - The Makefile now works on FreeBSD. It should also work on other BSD although it has not been tested. diff --git a/acmed/src/certificate.rs b/acmed/src/certificate.rs index 100dfe5..d969aee 100644 --- a/acmed/src/certificate.rs +++ b/acmed/src/certificate.rs @@ -61,6 +61,7 @@ pub struct Certificate { pub pk_file_group: Option, pub env: HashMap, pub id: usize, + pub renew_delay: Duration, } impl fmt::Display for Certificate { @@ -106,10 +107,7 @@ impl Certificate { "Certificate expires in {} days", expires_in.as_secs() / 86400 )); - // TODO: allow a custom duration (using time-parse ?) - // 1814400 is 3 weeks (3 * 7 * 24 * 60 * 60) - let renewal_time = Duration::new(1_814_400, 0); - Ok(expires_in <= renewal_time) + Ok(expires_in <= self.renew_delay) } fn has_missing_domains(&self, cert: &X509Certificate) -> bool { diff --git a/acmed/src/config.rs b/acmed/src/config.rs index 245cec9..f490e27 100644 --- a/acmed/src/config.rs +++ b/acmed/src/config.rs @@ -1,4 +1,5 @@ use crate::certificate::Algorithm; +use crate::duration::parse_duration; use crate::hooks; use acme_common::error::Error; use acme_common::to_idna; @@ -9,6 +10,7 @@ use std::fmt; use std::fs::{self, File}; use std::io::prelude::*; use std::path::{Path, PathBuf}; +use std::time::Duration; macro_rules! set_cfg_attr { ($to: expr, $from: expr) => { @@ -171,6 +173,16 @@ pub struct GlobalOptions { pub pk_file_group: Option, #[serde(default)] pub env: HashMap, + pub renew_delay: Option, +} + +impl GlobalOptions { + pub fn get_renew_delay(&self) -> Result { + match &self.renew_delay { + Some(d) => parse_duration(&d), + None => Ok(Duration::new(crate::DEFAULT_CERT_RENEW_DELAY, 0)), + } + } } #[derive(Clone, Deserialize)] @@ -183,9 +195,20 @@ pub struct Endpoint { pub rate_limits: Vec, pub key_type: Option, pub signature_algorithm: Option, + pub renew_delay: Option, } impl Endpoint { + pub fn get_renew_delay(&self, cnf: &Config) -> Result { + match &self.renew_delay { + Some(d) => parse_duration(&d), + None => match &cnf.global { + Some(g) => g.get_renew_delay(), + None => Ok(Duration::new(crate::DEFAULT_CERT_RENEW_DELAY, 0)), + }, + } + } + fn to_generic(&self, cnf: &Config) -> Result { let mut limits = vec![]; for rl_name in self.rate_limits.iter() { @@ -277,6 +300,7 @@ pub struct Certificate { pub hooks: Vec, #[serde(default)] pub env: HashMap, + pub renew_delay: Option, } impl Certificate { @@ -343,16 +367,20 @@ impl Certificate { crt_directory.to_string() } - pub fn get_endpoint(&self, cnf: &Config) -> Result { + fn do_get_endpoint(&self, cnf: &Config) -> Result { for endpoint in cnf.endpoint.iter() { if endpoint.name == self.endpoint { - let ep = endpoint.to_generic(cnf)?; - return Ok(ep); + return Ok(endpoint.clone()); } } Err(format!("{}: unknown endpoint.", self.endpoint).into()) } + pub fn get_endpoint(&self, cnf: &Config) -> Result { + let endpoint = self.do_get_endpoint(cnf)?; + endpoint.to_generic(cnf) + } + pub fn get_hooks(&self, cnf: &Config) -> Result, Error> { let mut res = vec![]; for name in self.hooks.iter() { @@ -361,6 +389,16 @@ impl Certificate { } Ok(res) } + + pub fn get_renew_delay(&self, cnf: &Config) -> Result { + match &self.renew_delay { + Some(d) => parse_duration(&d), + None => { + let endpoint = self.do_get_endpoint(cnf)?; + endpoint.get_renew_delay(cnf) + } + } + } } #[derive(Clone, Debug, Deserialize)] diff --git a/acmed/src/duration.rs b/acmed/src/duration.rs new file mode 100644 index 0000000..c1e104b --- /dev/null +++ b/acmed/src/duration.rs @@ -0,0 +1,51 @@ +use acme_common::error::Error; +use nom::bytes::complete::take_while_m_n; +use nom::character::complete::digit1; +use nom::combinator::map_res; +use nom::multi::fold_many1; +use nom::IResult; +use std::time::Duration; + +fn is_duration_chr(c: char) -> bool { + c == 's' || c == 'm' || c == 'h' || c == 'd' || c == 'w' +} + +fn get_multiplicator(input: &str) -> IResult<&str, u64> { + let (input, nb) = take_while_m_n(1, 1, is_duration_chr)(input)?; + let mult = match nb.chars().next() { + Some('s') => 1, + Some('m') => 60, + Some('h') => 3_600, + Some('d') => 86_400, + Some('w') => 604_800, + _ => 0, + }; + Ok((input, mult)) +} + +fn get_duration_part(input: &str) -> IResult<&str, Duration> { + let (input, nb) = map_res(digit1, |s: &str| s.parse::())(input)?; + let (input, mult) = get_multiplicator(input)?; + Ok((input, Duration::from_secs(nb * mult))) +} + +fn get_duration(input: &str) -> IResult<&str, Duration> { + fold_many1( + get_duration_part, + Duration::new(0, 0), + |mut acc: Duration, item| { + acc += item; + acc + }, + )(input) +} + +pub fn parse_duration(input: &str) -> Result { + match get_duration(input) { + Ok((r, d)) => match r.len() { + 0 => Ok(d), + _ => Err(format!("{}: invalid duration", input).into()), + }, + Err(_) => Err(format!("{}: invalid duration", input).into()), + } +} diff --git a/acmed/src/endpoint.rs b/acmed/src/endpoint.rs index dc8fe9c..68a9317 100644 --- a/acmed/src/endpoint.rs +++ b/acmed/src/endpoint.rs @@ -1,11 +1,7 @@ use crate::acme_proto::structs::Directory; +use crate::duration::parse_duration; use acme_common::crypto::{JwsSignatureAlgorithm, KeyType}; use acme_common::error::Error; -use nom::bytes::complete::take_while_m_n; -use nom::character::complete::digit1; -use nom::combinator::map_res; -use nom::multi::fold_many1; -use nom::IResult; use std::cmp; use std::str::FromStr; use std::thread; @@ -140,47 +136,3 @@ impl RateLimit { } } } - -fn is_duration_chr(c: char) -> bool { - c == 's' || c == 'm' || c == 'h' || c == 'd' || c == 'w' -} - -fn get_multiplicator(input: &str) -> IResult<&str, u64> { - let (input, nb) = take_while_m_n(1, 1, is_duration_chr)(input)?; - let mult = match nb.chars().next() { - Some('s') => 1, - Some('m') => 60, - Some('h') => 3_600, - Some('d') => 86_400, - Some('w') => 604_800, - _ => 0, - }; - Ok((input, mult)) -} - -fn get_duration_part(input: &str) -> IResult<&str, Duration> { - let (input, nb) = map_res(digit1, |s: &str| s.parse::())(input)?; - let (input, mult) = get_multiplicator(input)?; - Ok((input, Duration::from_secs(nb * mult))) -} - -fn get_duration(input: &str) -> IResult<&str, Duration> { - fold_many1( - get_duration_part, - Duration::new(0, 0), - |mut acc: Duration, item| { - acc += item; - acc - }, - )(input) -} - -fn parse_duration(input: &str) -> Result { - match get_duration(input) { - Ok((r, d)) => match r.len() { - 0 => Ok(d), - _ => Err(format!("{}: invalid duration", input).into()), - }, - Err(_) => Err(format!("{}: invalid duration", input).into()), - } -} diff --git a/acmed/src/main.rs b/acmed/src/main.rs index eabf974..768cffa 100644 --- a/acmed/src/main.rs +++ b/acmed/src/main.rs @@ -6,6 +6,7 @@ use log::error; mod acme_proto; mod certificate; mod config; +mod duration; mod endpoint; mod hooks; mod http; @@ -23,6 +24,7 @@ pub const DEFAULT_CERT_FORMAT: &str = "{{name}}_{{algo}}.{{file_type}}.{{ext}}"; pub const DEFAULT_SLEEP_TIME: u64 = 3600; pub const DEFAULT_POOL_TIME: u64 = 5000; pub const DEFAULT_CERT_FILE_MODE: u32 = 0o644; +pub const DEFAULT_CERT_RENEW_DELAY: u64 = 1_814_400; // 1_814_400 is 3 weeks (3 * 7 * 24 * 60 * 60) pub const DEFAULT_PK_FILE_MODE: u32 = 0o600; pub const DEFAULT_ACCOUNT_FILE_MODE: u32 = 0o600; pub const DEFAULT_KP_REUSE: bool = false; diff --git a/acmed/src/main_event_loop.rs b/acmed/src/main_event_loop.rs index 2f9e794..0f5e7ba 100644 --- a/acmed/src/main_event_loop.rs +++ b/acmed/src/main_event_loop.rs @@ -63,6 +63,7 @@ impl MainEventLoop { pk_file_group: cnf.get_pk_file_group(), env: crt.env.to_owned(), id: i + 1, + renew_delay: crt.get_renew_delay(&cnf)?, }; init_account(&cert, &endpoint)?; endpoints diff --git a/man/en/acmed.toml.5 b/man/en/acmed.toml.5 index d506af8..be1835e 100644 --- a/man/en/acmed.toml.5 +++ b/man/en/acmed.toml.5 @@ -62,6 +62,10 @@ for more details. Specify the group who will own newly-created private-key files. See .Xr chown 2 for more details. +.It Cm renew_delay Ar string +Period of time between the certificate renewal and its expiration date. The format is described in the +.Sx TIME PERIODS +section. Default is 3w. .El .It Ic rate-limit Array of table where each element defines a HTTPS rate limit. @@ -112,6 +116,10 @@ ES256 .It ES384 .El +.It Cm renew_delay Ar string +Period of time between the certificate renewal and its expiration date. The format is described in the +.Sx TIME PERIODS +section. Default is the value defined in the global section. .El .It Ic hook Array of table where each element defines a command that will be launched at a defined point. See section @@ -227,6 +235,10 @@ Set whether or not the private key should be reused when renewing the certificat Path to the directory where certificates and their associated private keys are stored. .It Ic hooks Ar array Names of hooks that will be called when requesting a new certificate. The hooks are guaranteed to be called sequentially in the declaration order. +.It Cm renew_delay Ar string +Period of time between the certificate renewal and its expiration date. The format is described in the +.Sx TIME PERIODS +section. Default is the value defined in the associated endpoint. .El .Sh WRITING A HOOK When requesting a certificate from a CA using ACME, there are three steps that are hard to automatize. The first one is solving challenges in order to prove the ownership of every domains to be included: It requires to interact with the configuration of other services, hence depends on how the infrastructure works. The second one is restarting all the services that use a given certificate, for the same reason. The last one is archiving: Although several default methods can be implemented, sometimes admins wants or are required to do it in a different way.