Skip to content

Commit e5da1e4

Browse files
committed
Add support for certbot hooks
1 parent bb73867 commit e5da1e4

File tree

16 files changed

+634
-37
lines changed

16 files changed

+634
-37
lines changed

README.md

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,43 @@ letsencrypt::certonly { 'foo':
187187
}
188188
```
189189

190-
#### Cron
190+
### Renewing certificates
191+
192+
There are two ways to automatically renew certificates with cron using this module.
193+
194+
#### cron using certbot renew
195+
196+
All installed certificates will be renewed using `certbot renew` using their
197+
original settings, including any not managed by Puppet.
198+
199+
* `renew_cron_ensure` manages the cron resource. Set to `present` to enable. Default: `absent`
200+
* `renew_cron_minute` sets minute(s) to run the cron job. Default: Seeded random minute
201+
* `renew_cron_hour` sets hour(s) to run the cron job. Default: Seeded random hour
202+
* `renew_cron_monthday` sets month day(s) to run the cron job. Default: Every day
203+
204+
```puppet
205+
class { 'letsencrypt':
206+
config => {
207+
email => '[email protected]',
208+
server => 'https://acme-v01.api.letsencrypt.org/directory',
209+
},
210+
renew_cron_ensure: 'present',
211+
}
212+
```
213+
214+
With Hiera, at 6 AM (roughly) every other day:
215+
216+
```yaml
217+
---
218+
letsencrypt::renew_cron_ensure: 'present'
219+
letsencrypt::renew_cron_minute: 0
220+
letsencrypt::renew_cron_hour: 6
221+
letsencrypt::renew_cron_monthday: '1-31/2'
222+
```
223+
224+
#### cron using certbot certonly
225+
226+
Only specific certificates will be renewed using `certbot certonly`.
191227

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

263+
## Hooks
264+
265+
Certbot supports hooks since certbot v0.5.0, however this module uses the newer
266+
`--deploy-hook` replacing the deprecated `--renew-hook`. Because of this the
267+
minimum version you will need to manage hooks with this module is v0.17.0.
268+
269+
All hook command parameters support both string and array.
270+
271+
**Note on certbot hook behavior:** Hooks created by `letsencrypt::certonly` will be
272+
configured in the renewal config file of the certificate by certbot (stored in
273+
CONFIG_DIR/renewal/), which means all hooks created this way are used when running
274+
`certbot renew` without hook arguments. This allows you to easily create individual
275+
hooks for each certificate with just one cron job for renewal. HOWEVER, when running
276+
`certbot renew` with any of the hook arguments (setting any of the
277+
`letsencrypt::renew_*_hook_commands` parameters), hooks of the corresponding
278+
types in all renewal configs will be ignored by certbot. It's recommended to keep
279+
these two ways of using hooks mutually exclusive to avoid confusion. Cron jobs
280+
created by `letsencrypt::certonly` are unaffected as they renew certificates
281+
directly using `certbot certonly`.
282+
283+
### certbot certonly
284+
285+
Hooks created with `letsencrypt::certonly` will behave the following way:
286+
287+
* `pre` hooks will be run before each certificate is attempted issued or renewed,
288+
even if the action fails.
289+
* `post` hooks will be run after each certificate is attempted issued or renewed,
290+
even if the action fails.
291+
* `deploy` hooks will be run after successfully issuing or renewing each certificate.
292+
It will not be run if no action is taken or if the action fails.
293+
294+
```puppet
295+
letsencrypt::certonly { 'foo':
296+
domains => ['foo.example.com', 'bar.example.com'],
297+
pre_hook_commands => ['...'],
298+
post_hook_commands => ['...'],
299+
deploy_hooks_commands => ['...'],
300+
}
301+
```
302+
303+
### certbot renew
304+
305+
Hooks passed to `certbot renew` will behave the following way:
306+
307+
* `pre` hook will be run once total before any certificates are attempted issued
308+
or renewed. It will not be run if no actions are taken. Overrides all pre hooks
309+
created by `letsencrypt::certonly`.
310+
* `post` hook will be run once total after all certificates are issued or renewed.
311+
It will not be run if no actions are taken. Overrides all post hooks created by
312+
`letsencrypt::certonly`.
313+
* `deploy` hook will be run once for each successfully issued or renewed certificate.
314+
It will not be run otherwise. Overrides all deploy hooks created by
315+
`letsencrypt::certonly`.
316+
317+
```puppet
318+
class { 'letsencrypt':
319+
config => {
320+
email => '[email protected]',
321+
server => 'https://acme-v01.api.letsencrypt.org/directory',
322+
},
323+
renew_pre_hook_commands: [...],
324+
renew_post_hook_commands: [...],
325+
renew_deploy_hook_commands: [...],
326+
}
327+
```
328+
329+
With Hiera:
330+
331+
```yaml
332+
---
333+
letsencrypt::renew_pre_hook_commands:
334+
- '...'
335+
letsencrypt::renew_post_hook_commands:
336+
- '...'
337+
letsencrypt::renew_deploy_hook_commands:
338+
- '...'
339+
```
340+
227341
## Development
228342

229343
1. Fork it

manifests/certonly.pp

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,17 @@
4242
# [*cron_minute*]
4343
# Optional string, integer or array, minute(s) that the renewal command should execute.
4444
# e.g. 0 or '00' or [0,30]. Default - seeded random minute.
45+
# [*pre_hook_commands*]
46+
# Array of commands to run in a shell before attempting to obtain/renew the certificate.
47+
# [*post_hook_commands*]
48+
# Array of command(s) to run in a shell after attempting to obtain/renew the certificate.
49+
# [*deploy_hook_commands*]
50+
# Array of command(s) to run in a shell once if the certificate is successfully issued.
51+
# Two environmental variables are supplied by certbot:
52+
# - $RENEWED_LINEAGE: Points to the live directory with the cert files and key.
53+
# Example: /etc/letsencrypt/live/example.com
54+
# - $RENEWED_DOMAINS: A space-delimited list of renewed certificate domains.
55+
# Example: "example.com www.example.com"
4556
#
4657
define letsencrypt::certonly (
4758
Enum['present','absent'] $ensure = 'present',
@@ -61,67 +72,91 @@
6172
Variant[Integer[0,23], String, Array] $cron_hour = fqdn_rand(24, $title),
6273
Variant[Integer[0,59], String, Array] $cron_minute = fqdn_rand(60, fqdn_rand_string(10, $title)),
6374
Stdlib::Unixpath $config_dir = $letsencrypt::config_dir,
75+
Variant[String[1], Array[String[1]]] $pre_hook_commands = [],
76+
Variant[String[1], Array[String[1]]] $post_hook_commands = [],
77+
Variant[String[1], Array[String[1]]] $deploy_hook_commands = [],
6478
) {
6579

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

84+
# Wildcard-less title for use in file paths
85+
$title_nowc = regsubst($title, '^\*\.', '')
86+
7087
if $ensure == 'present' {
7188
if ($custom_plugin) {
72-
$command_start = "${letsencrypt_command} --text --agree-tos --non-interactive certonly --rsa-key-size ${key_size} "
89+
$default_args = "--text --agree-tos --non-interactive certonly --rsa-key-size ${key_size}"
7390
} else {
74-
$command_start = "${letsencrypt_command} --text --agree-tos --non-interactive certonly --rsa-key-size ${key_size} -a ${plugin} "
91+
$default_args = "--text --agree-tos --non-interactive certonly --rsa-key-size ${key_size} -a ${plugin}"
7592
}
7693
} else {
77-
$command_start = "${letsencrypt_command} --text --agree-tos --non-interactive delete "
94+
$default_args = '--text --agree-tos --non-interactive delete'
7895
}
7996

8097
case $plugin {
8198

8299
'webroot': {
83-
$_command_domains = zip($domains, $webroot_paths).map |$domain| {
100+
$_plugin_args = zip($domains, $webroot_paths).map |$domain| {
84101
if $domain[1] {
85102
"--webroot-path ${domain[1]} -d ${domain[0]}"
86103
} else {
87104
"-d ${domain[0]}"
88105
}
89106
}
90-
$command_domains = join([ "--cert-name ${title}", ] + $_command_domains, ' ')
107+
$plugin_args = ["--cert-name ${title}"] + $_plugin_args
91108
}
92109

93110
'dns-rfc2136': {
94111
require letsencrypt::plugin::dns_rfc2136
95-
$dns_args = [
112+
$plugin_args = [
96113
"--cert-name ${title} -d",
97114
join($domains, ' -d '),
98115
"--dns-rfc2136-credentials ${letsencrypt::plugin::dns_rfc2136::config_dir}/dns-rfc2136.ini",
99116
"--dns-rfc2136-propagation-seconds ${letsencrypt::plugin::dns_rfc2136::propagation_seconds}",
100117
]
101-
$command_domains = join($dns_args, ' ')
102118
}
103119

104120
default: {
105121
if $ensure == 'present' {
106-
$_command_domains = join($domains, ' -d ')
107-
$command_domains = "--cert-name ${title} -d ${_command_domains}"
122+
$_plugin_args = join($domains, ' -d ')
123+
$plugin_args = "--cert-name ${title} -d ${_plugin_args}"
108124
} else {
109-
$command_domains = "--cert-name ${title}"
125+
$plugin_args = "--cert-name ${title}"
110126
}
111127
}
112128
}
113129

114-
if empty($additional_args) {
115-
$command_end = undef
116-
} else {
117-
# ['',] adds an additional whitespace in the front
118-
$command_end = join(['',] + $additional_args, ' ')
130+
$hook_args = ['pre', 'post', 'deploy'].map | String $type | {
131+
$commands = getvar("${type}_hook_commands")
132+
if (!empty($commands)) {
133+
$hook_file = "${config_dir}/renewal-hooks-puppet/${title_nowc}-${type}.sh"
134+
letsencrypt::hook { "${title}-${type}":
135+
type => $type,
136+
hook_file => $hook_file,
137+
commands => $commands,
138+
before => Exec["letsencrypt certonly ${title}"],
139+
}
140+
"--${type}-hook \"${hook_file}\""
141+
}
142+
else {
143+
undef
144+
}
119145
}
120146

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

151+
$_command = flatten([
152+
$letsencrypt_command,
153+
$default_args,
154+
$plugin_args,
155+
$hook_args,
156+
$additional_args,
157+
]).filter | $arg | { $arg =~ NotUndef and $arg != [] }
158+
$command = join($_command, ' ')
159+
125160
$execution_environment = [ "VENV_PATH=${letsencrypt::venv_path}", ] + $environment
126161
$verify_domains = join(unique($domains), ' ')
127162

@@ -132,7 +167,7 @@
132167
}
133168

134169
exec { "letsencrypt certonly ${title}":
135-
command => "${command_start}${command_domains}${command_end}",
170+
command => $command,
136171
* => $exec_ensure,
137172
path => $facts['path'],
138173
environment => $execution_environment,
@@ -144,7 +179,7 @@
144179
}
145180

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

manifests/hook.pp

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# == Defined Type: letsencrypt::hook
2+
#
3+
# This type is used by letsencrypt::renew and letsencrypt::certonly to create
4+
# hook scripts.
5+
#
6+
# === Parameters:
7+
#
8+
# [*type*]
9+
# Hook type. Can be pre, post or deploy.
10+
# [*hook_file*]
11+
# Path to deploy hook script.
12+
# [*commands*]
13+
# String or array of bash commands to execute when the hook is run by certbot.
14+
#
15+
define letsencrypt::hook (
16+
Enum['pre', 'post', 'deploy'] $type,
17+
String[1] $hook_file,
18+
# hook.sh.epp will validate this
19+
$commands,
20+
) {
21+
22+
$validate_env = $type ? {
23+
'deploy' => true,
24+
default => false,
25+
}
26+
27+
file { $hook_file:
28+
ensure => file,
29+
owner => 'root',
30+
group => 'root',
31+
mode => '0755',
32+
content => epp('letsencrypt/hook.sh.epp', {
33+
commands => $commands,
34+
validate_env => $validate_env,
35+
}),
36+
# Defined in letsencrypt::config
37+
require => File['letsencrypt-renewal-hooks-puppet'],
38+
}
39+
40+
}

manifests/init.pp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,21 @@
7373
Boolean $unsafe_registration = $letsencrypt::params::unsafe_registration,
7474
Stdlib::Unixpath $config_dir = $letsencrypt::params::config_dir,
7575
Integer[2048] $key_size = 4096,
76+
# $renew_* should only be used in letsencrypt::renew (blame rspec)
77+
$renew_pre_hook_commands = $letsencrypt::params::renew_pre_hook_commands,
78+
$renew_post_hook_commands = $letsencrypt::params::renew_post_hook_commands,
79+
$renew_deploy_hook_commands = $letsencrypt::params::renew_deploy_hook_commands,
80+
$renew_additional_args = $letsencrypt::params::renew_additional_args,
81+
$renew_cron_ensure = $letsencrypt::params::renew_cron_ensure,
82+
$renew_cron_hour = $letsencrypt::params::renew_cron_hour,
83+
$renew_cron_minute = $letsencrypt::params::renew_cron_minute,
84+
$renew_cron_monthday = $letsencrypt::params::renew_cron_monthday,
7685
) inherits letsencrypt::params {
7786

7887
if $manage_install {
7988
contain letsencrypt::install # lint:ignore:relative_classname_inclusion
8089
Class['letsencrypt::install'] ~> Exec['initialize letsencrypt']
90+
Class['letsencrypt::install'] -> Class['letsencrypt::renew']
8191
}
8292

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

108+
contain letsencrypt::renew
109+
98110
# TODO: do we need this command when installing from package?
99111
exec { 'initialize letsencrypt':
100112
command => "${command_init} -h",

manifests/params.pp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@
7070
default => 'root',
7171
}
7272

73+
$renew_pre_hook_commands = []
74+
$renew_post_hook_commands = []
75+
$renew_deploy_hook_commands = []
76+
$renew_additional_args = []
77+
$renew_cron_ensure = 'absent'
78+
$renew_cron_hour = fqdn_rand(24)
79+
$renew_cron_minute = fqdn_rand(60, fqdn_rand_string(10))
80+
$renew_cron_monthday = '*'
81+
7382
$dns_rfc2136_manage_package = true
7483
$dns_rfc2136_port = 53
7584
$dns_rfc2136_algorithm = 'HMAC-SHA512'

0 commit comments

Comments
 (0)