Skip to content

Commit f0d6009

Browse files
committed
Avoid race condition when renewing certificates
Only single certificate can be processed at the same time. Make sure to obtain exclusive lock before executing renewal command.
1 parent 84e2e60 commit f0d6009

File tree

4 files changed

+80
-13
lines changed

4 files changed

+80
-13
lines changed

files/certbot_lock.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Managed by Puppet
2+
LOCKFILE="/var/lock/certbot"
3+
LOCKFD=99
4+
# private
5+
function _lock() { flock "$@" $LOCKFD; }
6+
function _release_lock() { _lock -u; _lock -xn && rm -f $LOCKFILE; }
7+
function _prepare_lock() { eval "exec $LOCKFD>\"$LOCKFILE\""; trap _release_lock EXIT; }
8+
9+
# on start
10+
_prepare_lock
11+
12+
# public
13+
function exlock() { _lock -x --timeout 30; } # obtain an exclusive lock
14+
function unlock() { _lock -u; } # drop a lock

manifests/init.pp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@
105105

106106
contain letsencrypt::renew
107107

108+
file { '/usr/share/certbot_lock.sh':
109+
ensure => file,
110+
mode => '0544',
111+
content => file("${module_name}/certbot_lock.sh"),
112+
}
113+
108114
$certificates.each |$certificate, $properties| {
109115
letsencrypt::certonly { $certificate: * => $properties }
110116
}

spec/defines/letsencrypt_certonly_spec.rb

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
require 'spec_helper'
44

5+
def renew_command(cmd, env = [])
6+
environ = ''
7+
env.each do |e|
8+
environ += "\nexport #{e}"
9+
end
10+
<<~EOS
11+
#!/bin/sh#{environ}
12+
if [ -f '/usr/share/certbot_lock.sh' ]; then
13+
. '/usr/share/certbot_lock.sh'
14+
fi
15+
exlock
16+
#{cmd}
17+
unlock
18+
EOS
19+
end
20+
521
describe 'letsencrypt::certonly' do
622
on_supported_os.each do |os, facts|
723
context "on #{os} based operating systems" do
@@ -225,7 +241,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
225241

226242
it { is_expected.to compile.with_all_deps }
227243
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_command('"/var/lib/puppet/letsencrypt/renew-foo.example.com.sh"').with_ensure('present') }
228-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a apache --cert-name 'foo.example.com' -d 'foo.example.com'\n") }
244+
245+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a apache --cert-name 'foo.example.com' -d 'foo.example.com'")
246+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
229247
end
230248

231249
context 'with hook' do
@@ -281,7 +299,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
281299

282300
it { is_expected.to compile.with_all_deps }
283301
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_hour(13).with_ensure('present') }
284-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'\n") }
302+
303+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'")
304+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
285305
end
286306

287307
context 'with manage_cron and out of range defined cron_hour (integer)' do
@@ -308,7 +328,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
308328

309329
it { is_expected.to compile.with_all_deps }
310330
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_hour('00').with_ensure('present') }
311-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'\n") }
331+
332+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'")
333+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
312334
end
313335

314336
context 'with manage_cron and defined cron_hour (array)' do
@@ -322,7 +344,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
322344

323345
it { is_expected.to compile.with_all_deps }
324346
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_hour([1, 13]).with_ensure('present') }
325-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'\n") }
347+
348+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'")
349+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
326350
end
327351

328352
context 'with manage_cron and defined cron_minute (integer)' do
@@ -336,7 +360,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
336360

337361
it { is_expected.to compile.with_all_deps }
338362
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_minute(15).with_ensure('present') }
339-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'\n") }
363+
364+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'")
365+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
340366
end
341367

342368
context 'with manage_cron and defined cron_minute (string)' do
@@ -350,7 +376,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
350376

351377
it { is_expected.to compile.with_all_deps }
352378
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_minute('15').with_ensure('present') }
353-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'\n") }
379+
380+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'")
381+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
354382
end
355383

356384
context 'with manage_cron and defined cron_minute (array)' do
@@ -364,7 +392,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
364392

365393
it { is_expected.to compile.with_all_deps }
366394
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_minute([0, 30]).with_ensure('present') }
367-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'\n") }
395+
396+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'")
397+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
368398
end
369399

370400
context 'with manage_cron and ensure absent' do
@@ -394,7 +424,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
394424
it { is_expected.to compile.with_all_deps }
395425
it { is_expected.to contain_file('/tmp/custom_vardir/letsencrypt').with_ensure('directory') }
396426
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_command '"/tmp/custom_vardir/letsencrypt/renew-foo.example.com.sh"' }
397-
it { is_expected.to contain_file('/tmp/custom_vardir/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a apache --cert-name 'foo.example.com' -d 'foo.example.com'\n") }
427+
428+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a apache --cert-name 'foo.example.com' -d 'foo.example.com'")
429+
it { is_expected.to contain_file('/tmp/custom_vardir/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
398430
end
399431

400432
context 'with custom plugin and manage cron and cron_success_command' do
@@ -410,7 +442,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
410442

411443
it { is_expected.to compile.with_all_deps }
412444
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_command '"/var/lib/puppet/letsencrypt/renew-foo.example.com.sh"' }
413-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\n(echo before) && letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a apache --cert-name 'foo.example.com' -d 'foo.example.com' && (echo success)\n") }
445+
446+
script = renew_command("(echo before) && letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a apache --cert-name 'foo.example.com' -d 'foo.example.com' && (echo success)")
447+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
414448
end
415449

416450
context 'without plugin' do
@@ -449,7 +483,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
449483
let(:params) { { environment: ['FOO=bar', 'FIZZ=buzz'], manage_cron: true } }
450484

451485
it { is_expected.to compile.with_all_deps }
452-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_content "#!/bin/sh\nexport FOO=bar\nexport FIZZ=buzz\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'\n" }
486+
487+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'", ['FOO=bar', 'FIZZ=buzz'])
488+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_content(script) }
453489
end
454490

455491
context 'with manage cron and cron_output=suppress' do\
@@ -461,7 +497,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
461497

462498
it { is_expected.to compile.with_all_deps }
463499
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_command('"/var/lib/puppet/letsencrypt/renew-foo.example.com.sh"').with_ensure('present') }
464-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com' > /dev/null 2>&1\n") }
500+
501+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com' > /dev/null 2>&1")
502+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
465503
end
466504

467505
context 'with manage cron and cron_output=log' do\
@@ -473,7 +511,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
473511

474512
it { is_expected.to compile.with_all_deps }
475513
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with_command('"/var/lib/puppet/letsencrypt/renew-foo.example.com.sh"').with_ensure('present') }
476-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com' 2>&1 | logger -t letsencrypt-renew\n") }
514+
515+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com' 2>&1 | logger -t letsencrypt-renew")
516+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
477517
end
478518

479519
context 'with manage cron and custom day of month' do
@@ -485,7 +525,9 @@ class { 'letsencrypt::plugin::dns_cloudflare':
485525

486526
it { is_expected.to compile.with_all_deps }
487527
it { is_expected.to contain_cron('letsencrypt renew cron foo.example.com').with(monthday: [1, 15]).with_ensure('present') }
488-
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content("#!/bin/sh\nletsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'\n") }
528+
529+
script = renew_command("letsencrypt --keep-until-expiring --text --agree-tos --non-interactive certonly --rsa-key-size 4096 -a standalone --cert-name 'foo.example.com' -d 'foo.example.com'")
530+
it { is_expected.to contain_file('/var/lib/puppet/letsencrypt/renew-foo.example.com.sh').with_ensure('file').with_content(script) }
489531
end
490532

491533
context 'with custom config_dir' do

templates/renew-script.sh.erb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,9 @@
22
<%- @environment.each do |environment| -%>
33
export <%= environment %>
44
<%- end -%>
5+
if [ -f '/usr/share/certbot_lock.sh' ]; then
6+
. '/usr/share/certbot_lock.sh'
7+
fi
8+
exlock
59
<%= @cron_cmd %>
10+
unlock

0 commit comments

Comments
 (0)