Skip to content

Commit

Permalink
Merge pull request #174 from Rathios/renew
Browse files Browse the repository at this point in the history
Add support for certbot renew and hooks
  • Loading branch information
Dan33l authored Mar 23, 2019
2 parents d306773 + e5da1e4 commit 1d39b2b
Show file tree
Hide file tree
Showing 17 changed files with 637 additions and 38 deletions.
116 changes: 115 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,43 @@ letsencrypt::certonly { 'foo':
}
```

#### Cron
### Renewing certificates

There are two ways to automatically renew certificates with cron using this module.

#### cron using certbot renew

All installed certificates will be renewed using `certbot renew` using their
original settings, including any not managed by Puppet.

* `renew_cron_ensure` manages the cron resource. Set to `present` to enable. Default: `absent`
* `renew_cron_minute` sets minute(s) to run the cron job. Default: Seeded random minute
* `renew_cron_hour` sets hour(s) to run the cron job. Default: Seeded random hour
* `renew_cron_monthday` sets month day(s) to run the cron job. Default: Every day

```puppet
class { 'letsencrypt':
config => {
email => '[email protected]',
server => 'https://acme-v01.api.letsencrypt.org/directory',
},
renew_cron_ensure: 'present',
}
```

With Hiera, at 6 AM (roughly) every other day:

```yaml
---
letsencrypt::renew_cron_ensure: 'present'
letsencrypt::renew_cron_minute: 0
letsencrypt::renew_cron_hour: 6
letsencrypt::renew_cron_monthday: '1-31/2'
```
#### cron using certbot certonly
Only specific certificates will be renewed using `certbot certonly`.

* `manage_cron` can be used to automatically renew the certificate
* `cron_success_command` can be used to run a shell command on a successful renewal
Expand Down Expand Up @@ -224,6 +260,84 @@ letsencrypt::certonly { 'foo':
}
```

## Hooks

Certbot supports hooks since certbot v0.5.0, however this module uses the newer
`--deploy-hook` replacing the deprecated `--renew-hook`. Because of this the
minimum version you will need to manage hooks with this module is v0.17.0.

All hook command parameters support both string and array.

**Note on certbot hook behavior:** Hooks created by `letsencrypt::certonly` will be
configured in the renewal config file of the certificate by certbot (stored in
CONFIG_DIR/renewal/), which means all hooks created this way are used when running
`certbot renew` without hook arguments. This allows you to easily create individual
hooks for each certificate with just one cron job for renewal. HOWEVER, when running
`certbot renew` with any of the hook arguments (setting any of the
`letsencrypt::renew_*_hook_commands` parameters), hooks of the corresponding
types in all renewal configs will be ignored by certbot. It's recommended to keep
these two ways of using hooks mutually exclusive to avoid confusion. Cron jobs
created by `letsencrypt::certonly` are unaffected as they renew certificates
directly using `certbot certonly`.

### certbot certonly

Hooks created with `letsencrypt::certonly` will behave the following way:

* `pre` hooks will be run before each certificate is attempted issued or renewed,
even if the action fails.
* `post` hooks will be run after each certificate is attempted issued or renewed,
even if the action fails.
* `deploy` hooks will be run after successfully issuing or renewing each certificate.
It will not be run if no action is taken or if the action fails.

```puppet
letsencrypt::certonly { 'foo':
domains => ['foo.example.com', 'bar.example.com'],
pre_hook_commands => ['...'],
post_hook_commands => ['...'],
deploy_hooks_commands => ['...'],
}
```

### certbot renew

Hooks passed to `certbot renew` will behave the following way:

* `pre` hook will be run once total before any certificates are attempted issued
or renewed. It will not be run if no actions are taken. Overrides all pre hooks
created by `letsencrypt::certonly`.
* `post` hook will be run once total after all certificates are issued or renewed.
It will not be run if no actions are taken. Overrides all post hooks created by
`letsencrypt::certonly`.
* `deploy` hook will be run once for each successfully issued or renewed certificate.
It will not be run otherwise. Overrides all deploy hooks created by
`letsencrypt::certonly`.

```puppet
class { 'letsencrypt':
config => {
email => '[email protected]',
server => 'https://acme-v01.api.letsencrypt.org/directory',
},
renew_pre_hook_commands: [...],
renew_post_hook_commands: [...],
renew_deploy_hook_commands: [...],
}
```

With Hiera:

```yaml
---
letsencrypt::renew_pre_hook_commands:
- '...'
letsencrypt::renew_post_hook_commands:
- '...'
letsencrypt::renew_deploy_hook_commands:
- '...'
```

## Development

1. Fork it
Expand Down
69 changes: 52 additions & 17 deletions manifests/certonly.pp
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@
# [*cron_minute*]
# Optional string, integer or array, minute(s) that the renewal command should execute.
# e.g. 0 or '00' or [0,30]. Default - seeded random minute.
# [*pre_hook_commands*]
# Array of commands to run in a shell before attempting to obtain/renew the certificate.
# [*post_hook_commands*]
# Array of command(s) to run in a shell after attempting to obtain/renew the certificate.
# [*deploy_hook_commands*]
# Array of command(s) to run in a shell once if the certificate is successfully issued.
# Two environmental variables are supplied by certbot:
# - $RENEWED_LINEAGE: Points to the live directory with the cert files and key.
# Example: /etc/letsencrypt/live/example.com
# - $RENEWED_DOMAINS: A space-delimited list of renewed certificate domains.
# Example: "example.com www.example.com"
#
define letsencrypt::certonly (
Enum['present','absent'] $ensure = 'present',
Expand All @@ -61,67 +72,91 @@
Variant[Integer[0,23], String, Array] $cron_hour = fqdn_rand(24, $title),
Variant[Integer[0,59], String, Array] $cron_minute = fqdn_rand(60, fqdn_rand_string(10, $title)),
Stdlib::Unixpath $config_dir = $letsencrypt::config_dir,
Variant[String[1], Array[String[1]]] $pre_hook_commands = [],
Variant[String[1], Array[String[1]]] $post_hook_commands = [],
Variant[String[1], Array[String[1]]] $deploy_hook_commands = [],
) {

if $plugin == 'webroot' and empty($webroot_paths) {
fail("The 'webroot_paths' parameter must be specified when using the 'webroot' plugin")
}

# Wildcard-less title for use in file paths
$title_nowc = regsubst($title, '^\*\.', '')

if $ensure == 'present' {
if ($custom_plugin) {
$command_start = "${letsencrypt_command} --text --agree-tos --non-interactive certonly --rsa-key-size ${key_size} "
$default_args = "--text --agree-tos --non-interactive certonly --rsa-key-size ${key_size}"
} else {
$command_start = "${letsencrypt_command} --text --agree-tos --non-interactive certonly --rsa-key-size ${key_size} -a ${plugin} "
$default_args = "--text --agree-tos --non-interactive certonly --rsa-key-size ${key_size} -a ${plugin}"
}
} else {
$command_start = "${letsencrypt_command} --text --agree-tos --non-interactive delete "
$default_args = '--text --agree-tos --non-interactive delete'
}

case $plugin {

'webroot': {
$_command_domains = zip($domains, $webroot_paths).map |$domain| {
$_plugin_args = zip($domains, $webroot_paths).map |$domain| {
if $domain[1] {
"--webroot-path ${domain[1]} -d ${domain[0]}"
} else {
"-d ${domain[0]}"
}
}
$command_domains = join([ "--cert-name ${title}", ] + $_command_domains, ' ')
$plugin_args = ["--cert-name ${title}"] + $_plugin_args
}

'dns-rfc2136': {
require letsencrypt::plugin::dns_rfc2136
$dns_args = [
$plugin_args = [
"--cert-name ${title} -d",
join($domains, ' -d '),
"--dns-rfc2136-credentials ${letsencrypt::plugin::dns_rfc2136::config_dir}/dns-rfc2136.ini",
"--dns-rfc2136-propagation-seconds ${letsencrypt::plugin::dns_rfc2136::propagation_seconds}",
]
$command_domains = join($dns_args, ' ')
}

default: {
if $ensure == 'present' {
$_command_domains = join($domains, ' -d ')
$command_domains = "--cert-name ${title} -d ${_command_domains}"
$_plugin_args = join($domains, ' -d ')
$plugin_args = "--cert-name ${title} -d ${_plugin_args}"
} else {
$command_domains = "--cert-name ${title}"
$plugin_args = "--cert-name ${title}"
}
}
}

if empty($additional_args) {
$command_end = undef
} else {
# ['',] adds an additional whitespace in the front
$command_end = join(['',] + $additional_args, ' ')
$hook_args = ['pre', 'post', 'deploy'].map | String $type | {
$commands = getvar("${type}_hook_commands")
if (!empty($commands)) {
$hook_file = "${config_dir}/renewal-hooks-puppet/${title_nowc}-${type}.sh"
letsencrypt::hook { "${title}-${type}":
type => $type,
hook_file => $hook_file,
commands => $commands,
before => Exec["letsencrypt certonly ${title}"],
}
"--${type}-hook \"${hook_file}\""
}
else {
undef
}
}

# certbot uses --cert-name to generate the file path
$live_path_certname = regsubst($title, '^\*\.', '')
$live_path = "${config_dir}/live/${live_path_certname}/cert.pem"

$_command = flatten([
$letsencrypt_command,
$default_args,
$plugin_args,
$hook_args,
$additional_args,
]).filter | $arg | { $arg =~ NotUndef and $arg != [] }
$command = join($_command, ' ')

$execution_environment = [ "VENV_PATH=${letsencrypt::venv_path}", ] + $environment
$verify_domains = join(unique($domains), ' ')

Expand All @@ -132,7 +167,7 @@
}

exec { "letsencrypt certonly ${title}":
command => "${command_start}${command_domains}${command_end}",
command => $command,
* => $exec_ensure,
path => $facts['path'],
environment => $execution_environment,
Expand All @@ -144,7 +179,7 @@
}

if $manage_cron {
$maincommand = "${command_start}--keep-until-expiring ${command_domains}${command_end}"
$maincommand = join($_command + ['--keep-until-expiring'], ' ')
$cron_script_ensure = $ensure ? { 'present' => 'file', default => 'absent' }
$cron_ensure = $ensure

Expand Down
40 changes: 40 additions & 0 deletions manifests/hook.pp
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# == Defined Type: letsencrypt::hook
#
# This type is used by letsencrypt::renew and letsencrypt::certonly to create
# hook scripts.
#
# === Parameters:
#
# [*type*]
# Hook type. Can be pre, post or deploy.
# [*hook_file*]
# Path to deploy hook script.
# [*commands*]
# String or array of bash commands to execute when the hook is run by certbot.
#
define letsencrypt::hook (
Enum['pre', 'post', 'deploy'] $type,
String[1] $hook_file,
# hook.sh.epp will validate this
$commands,
) {

$validate_env = $type ? {
'deploy' => true,
default => false,
}

file { $hook_file:
ensure => file,
owner => 'root',
group => 'root',
mode => '0755',
content => epp('letsencrypt/hook.sh.epp', {
commands => $commands,
validate_env => $validate_env,
}),
# Defined in letsencrypt::config
require => File['letsencrypt-renewal-hooks-puppet'],
}

}
12 changes: 12 additions & 0 deletions manifests/init.pp
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,21 @@
Boolean $unsafe_registration = $letsencrypt::params::unsafe_registration,
Stdlib::Unixpath $config_dir = $letsencrypt::params::config_dir,
Integer[2048] $key_size = 4096,
# $renew_* should only be used in letsencrypt::renew (blame rspec)
$renew_pre_hook_commands = $letsencrypt::params::renew_pre_hook_commands,
$renew_post_hook_commands = $letsencrypt::params::renew_post_hook_commands,
$renew_deploy_hook_commands = $letsencrypt::params::renew_deploy_hook_commands,
$renew_additional_args = $letsencrypt::params::renew_additional_args,
$renew_cron_ensure = $letsencrypt::params::renew_cron_ensure,
$renew_cron_hour = $letsencrypt::params::renew_cron_hour,
$renew_cron_minute = $letsencrypt::params::renew_cron_minute,
$renew_cron_monthday = $letsencrypt::params::renew_cron_monthday,
) inherits letsencrypt::params {

if $manage_install {
contain letsencrypt::install # lint:ignore:relative_classname_inclusion
Class['letsencrypt::install'] ~> Exec['initialize letsencrypt']
Class['letsencrypt::install'] -> Class['letsencrypt::renew']
}

$command = $install_method ? {
Expand All @@ -95,6 +105,8 @@
Class['letsencrypt::config'] -> Exec['initialize letsencrypt']
}

contain letsencrypt::renew

# TODO: do we need this command when installing from package?
exec { 'initialize letsencrypt':
command => "${command_init} -h",
Expand Down
11 changes: 10 additions & 1 deletion manifests/params.pp
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
$package_name = 'app-crypt/certbot'
$package_command = 'certbot'
$config_dir = '/etc/letsencrypt'
} elsif $facts['osfamily'] == 'OpenBSD' {
} elsif $facts['osfamily'] == 'OpenBSD' {
$install_method = 'package'
$package_name = 'certbot'
$package_command = 'certbot'
Expand Down Expand Up @@ -70,6 +70,15 @@
default => 'root',
}

$renew_pre_hook_commands = []
$renew_post_hook_commands = []
$renew_deploy_hook_commands = []
$renew_additional_args = []
$renew_cron_ensure = 'absent'
$renew_cron_hour = fqdn_rand(24)
$renew_cron_minute = fqdn_rand(60, fqdn_rand_string(10))
$renew_cron_monthday = '*'

$dns_rfc2136_manage_package = true
$dns_rfc2136_port = 53
$dns_rfc2136_algorithm = 'HMAC-SHA512'
Expand Down
2 changes: 2 additions & 0 deletions manifests/plugin/dns_rfc2136.pp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
# TSIG key algorithm.
# [*port*]
# Target DNS port.
# [*propagation_seconds*]
# Number of seconds to wait for the DNS server to propagate the DNS-01 challenge.
# [*manage_package*]
# Manage the plugin package.
# [*config_dir*]
Expand Down
Loading

0 comments on commit 1d39b2b

Please sign in to comment.