From 696e473546419f964841ddd78b91b8b97a6e0181 Mon Sep 17 00:00:00 2001 From: Mischa Taylor <taylor@linux.com> Date: Fri, 10 Jan 2025 15:11:38 -0500 Subject: [PATCH] Add support for cerbot automation --- cookbooks/boxcutter_acme/README.md | 74 ++++++++++++++++++- .../boxcutter_acme/attributes/default.rb | 10 ++- cookbooks/boxcutter_acme/kitchen.yml | 10 +++ cookbooks/boxcutter_acme/libraries/default.rb | 8 ++ cookbooks/boxcutter_acme/recipes/certbot.rb | 74 +++++++++++++++++-- .../templates/certbot_renew.sh.erb | 50 +++++++++++++ .../templates/cloudflare.ini.erb | 2 + .../boxcutter_acme_test/recipes/certbot.rb | 22 ++++++ 8 files changed, 240 insertions(+), 10 deletions(-) create mode 100644 cookbooks/boxcutter_acme/libraries/default.rb create mode 100644 cookbooks/boxcutter_acme/templates/certbot_renew.sh.erb create mode 100644 cookbooks/boxcutter_acme/templates/cloudflare.ini.erb diff --git a/cookbooks/boxcutter_acme/README.md b/cookbooks/boxcutter_acme/README.md index 6e2c61e..19abaad 100644 --- a/cookbooks/boxcutter_acme/README.md +++ b/cookbooks/boxcutter_acme/README.md @@ -4,11 +4,73 @@ Configures ACME-based clients (Automated Certificate Management Environment) that make it possible to automate the issuance and renewal of SSL certificates without needing human interaction. -## Recipes +Two different systems are supported: +- certbot - Python-based Let's Encrypt client and ACME library. +- lego - Go languaged-based Let's Encrypt client and ACME library. -- `boxcutter_acme::lego` - Let’s Encrypt client and ACME library written in Go. +## Using certbot to automate the issuance and renewal of SSL certificates + +Add `include_recipe 'boxcutter_acme::certbot'` to install the certbot Let's +Encrypt client and ACME library. A Python virtual environment will be +created in `/opt/certbot/venv`. + +The certbot binary is installed in `/opt/certbot/venv/bin/certbot`. + +You can specify SSL certificate configurations to be managed under +`node['boxcutter_acme']['certbot']['config']`. + +For example: + +``` +node.default['boxcutter_acme']['certbot']['config'] = { + 'example' => { + 'domains' => 'server.example.com', + 'certbot_bin' => '/opt/certbot/venv/bin/certbot', + 'renew_script_path' => '/opt/certbot/bin/lego_renew.sh.erb', + 'email' => 'letsencrypt@example.com', + 'cloudflare_ini' => '/etc/chef/cloudflare.ini', + 'extra_args' => [ + '--dns-cloudflare', + '--dns-cloudflare-credentials /etc/chef/cloudflare.ini', + '--test-cert', + ].join(' '), + }, +} +``` + +### Fields + +Required fields: + +* `renew_script_path`: Full path where the automation should put the script + that obtains and renews +* `email`: Email used for registration and recovery contact. +* `domains`: Array containing the list of domain values to be added to the SSL + certificate +* `certbot_bin` + +Optional fields: + +* `config_dir`: Specifies the directory where Certbot saves its configuration + and certificates. Default: `/etc/letsencrypt`. +* `logs_dir`: Specifies the directory where Certbot saves logs. + Default: `/var/log/letsencrypt`. +* `work_dir`: Specifies the working directory for temporary files. + Default: `/var/lib/letsencrypt`. +* `certbot_bin` + + +* `renew_days`: The number of days left on a certificate to renew it. (default: 30) +* `server`: Let's Encrypt ACME server to be used. If you'd like to test + something without issuing real certificates, you can use the staging + endpoint `https://acme-staging-v02.api.letsencrypt.org/directory`. +* `extra_parameters`: Additional global options to be added to the command + line, not covered by required fields (`--dns-resolvers value`). Default is `--http`. +* `extra_environment`: Additional environment variables to be configured for + the renew script. Usually environment variables required for the DNS + tokens. -## Usage +## Using lego to automate the issuance and renewal of SSL certificates Add `include_recipe 'boxcutter_acme::lego'` to install the Let's Encryt client and ACME library for Go. The LEGO binaries will be installed to `/opt/lego` @@ -60,3 +122,9 @@ Optional fields: * `extra_environment`: Additional environment variables to be configured for the renew script. Usually environment variables required for the DNS tokens. + +## Recipes + +- `boxcutter_acme::lego` - Let’s Encrypt client and ACME library written in Go. + +References: https://github.com/schubergphilis/chef-acme/blob/master/README.md diff --git a/cookbooks/boxcutter_acme/attributes/default.rb b/cookbooks/boxcutter_acme/attributes/default.rb index 42abe26..d18c602 100644 --- a/cookbooks/boxcutter_acme/attributes/default.rb +++ b/cookbooks/boxcutter_acme/attributes/default.rb @@ -1,3 +1,9 @@ -default['boxcutter_acme']['lego'] = { - 'config' => {}, +default['boxcutter_acme'] = { + 'certbot' => { + 'cloudflare_api_key' => nil, + 'config' => {}, + }, + 'lego' => { + 'config' => {}, + }, } diff --git a/cookbooks/boxcutter_acme/kitchen.yml b/cookbooks/boxcutter_acme/kitchen.yml index 92235c6..d57db55 100644 --- a/cookbooks/boxcutter_acme/kitchen.yml +++ b/cookbooks/boxcutter_acme/kitchen.yml @@ -71,6 +71,16 @@ suites: inspec_tests: - test/integration/default attributes: + lifecycle: + pre_converge: + - remote: | + bash -xc ' + set +x + mkdir -p /etc/cinc + ln -s /etc/cinc /etc/chef + echo "<%= ENV['OP_SERVICE_ACCOUNT_TOKEN'] %>" > /etc/chef/op_service_account_token + set -x + ' - name: lego provisioner: diff --git a/cookbooks/boxcutter_acme/libraries/default.rb b/cookbooks/boxcutter_acme/libraries/default.rb new file mode 100644 index 0000000..8090894 --- /dev/null +++ b/cookbooks/boxcutter_acme/libraries/default.rb @@ -0,0 +1,8 @@ +module Boxcutter + class Acme + def self.to_bash_array(ruby_array) + bash_array = ruby_array.map { |item| "\"#{item}\"" }.join(' ') + "(#{bash_array})" + end + end +end diff --git a/cookbooks/boxcutter_acme/recipes/certbot.rb b/cookbooks/boxcutter_acme/recipes/certbot.rb index 411229d..b7e2dcf 100644 --- a/cookbooks/boxcutter_acme/recipes/certbot.rb +++ b/cookbooks/boxcutter_acme/recipes/certbot.rb @@ -18,14 +18,78 @@ include_recipe 'boxcutter_python::system' +%w{ + /opt/certbot + /opt/certbot/bin +}.each do |dir| + directory dir do + owner 'root' + group 'root' + mode '0700' + end +end + boxcutter_python_virtualenv '/opt/certbot/venv' -%w{ - certbot - certbot-dns-cloudflare -}.each do |pkg| - boxcutter_python_pip pkg do +boxcutter_python_pip 'certbot' do + virtualenv '/opt/certbot/venv' + action :upgrade +end + +# Only bother configuring cloudflare plugins if we're provided an api token +if node.exist?('boxcutter_acme', 'certbot', 'cloudflare_api_token') || + node.run_state.key?('boxcutter_acme') \ + && node.run_state['boxcutter_acme'].key?('certbot') \ + && node.run_state['boxcutter_acme']['certbot'].key?('cloudflare_api_token') + + boxcutter_python_pip 'certbot-dns-cloudflare' do virtualenv '/opt/certbot/venv' action :upgrade end + + cloudflare_api_token = node['boxcutter_acme']['certbot']['cloudflare_api_token'] + if node.run_state.key?('boxcutter_acme') \ + && node.run_state['boxcutter_acme'].key?('certbot') \ + && node.run_state['boxcutter_acme']['certbot'].key?('cloudflare_api_token') + cloudflare_api_token = node.run_state['boxcutter_acme']['certbot']['cloudflare_api_token'] + end + + template '/etc/chef/cloudflare.ini' do + source 'cloudflare.ini.erb' + owner 'root' + group 'root' + mode 0400 + variables( + cloudflare_api_token: cloudflare_api_token, + ) + end +end + +node.default['boxcutter_acme']['certbot']['config'].each do |name, config| + execute "#{name} obtain certificate" do + command config['renew_script_path'] + action :nothing + end + + template config['renew_script_path'] do + source 'certbot_renew.sh.erb' + owner 'root' + group 'root' + mode 0700 + variables( + certbot_bin: config['certbot_bin'], + domains: Boxcutter::Acme.to_bash_array(config['domains']), + email: config['email'], + cloudflare_ini: config['cloudflare_ini'], + extra_args: config['extra_args'], + ) + notifies :run, "execute[#{name} obtain certificate]", :immediately + end + + node.default['fb_timers']['jobs'][name] = { + 'calendar' => FB::Systemd::Calendar.every.weekday, + 'command' => config['renew_script_path'], + 'accuracy' => '1h', + 'splay' => '0.5h', + } end diff --git a/cookbooks/boxcutter_acme/templates/certbot_renew.sh.erb b/cookbooks/boxcutter_acme/templates/certbot_renew.sh.erb new file mode 100644 index 0000000..a81dd6b --- /dev/null +++ b/cookbooks/boxcutter_acme/templates/certbot_renew.sh.erb @@ -0,0 +1,50 @@ +#!/bin/bash + +CERTBOT_BIN="<%= @certbot_bin %>" +DOMAINS=<%= @domains %> +EMAIL="<%= @email %>" +CLOUDFLARE_INI="<%= @cloudflare_ini %>" + +check_certificate() { + for DOMAIN in "${DOMAINS[@]}"; do + if ! "${CERTBOT_BIN}" certificates | grep -q "Domains:.*\b$DOMAIN\b"; then + return 1 # If any domain is missing, return failure + fi + done + return 0 # All domains are covered +} + +obtain_certificate() { + if check_certificate; then + echo "Certificate for all domains already exists. Skipping certificate creation." + else + echo "Creating a new certificate for domains: ${DOMAINS[*]}" + DOMAIN_ARGS=() + for DOMAIN in ${DOMAINS}; do + DOMAIN_ARGS+=("-d $DOMAIN") + done + + "${CERTBOT_BIN}" certonly \ + --non-interactive \ + --agree-tos \ + --non-interactive \ + -m "${EMAIL}" \ + --no-eff-email \ + <%= @extra_args.nil? ? '' : "#{@extra_args} " -%>--preferred-challenges dns-01 \ + --expand \ + ${DOMAIN_ARGS[@]} + fi +} + +renew_certificate() { + echo "Attempting to renew SSL certificate for domains: ${DOMAINS[*]}" + "${CERTBOT_BIN}" renew +} + +certificate_info() { + "${CERTBOT_BIN}" certificates +} + +obtain_certificate +renew_certificate +certificate_info diff --git a/cookbooks/boxcutter_acme/templates/cloudflare.ini.erb b/cookbooks/boxcutter_acme/templates/cloudflare.ini.erb new file mode 100644 index 0000000..01964ef --- /dev/null +++ b/cookbooks/boxcutter_acme/templates/cloudflare.ini.erb @@ -0,0 +1,2 @@ +# Cloudflare API token used by Certbot +dns_cloudflare_api_token = <%= @cloudflare_api_token %> diff --git a/cookbooks/boxcutter_acme/test/cookbooks/boxcutter_acme_test/recipes/certbot.rb b/cookbooks/boxcutter_acme/test/cookbooks/boxcutter_acme_test/recipes/certbot.rb index 5987924..a265ac7 100644 --- a/cookbooks/boxcutter_acme/test/cookbooks/boxcutter_acme_test/recipes/certbot.rb +++ b/cookbooks/boxcutter_acme/test/cookbooks/boxcutter_acme_test/recipes/certbot.rb @@ -3,4 +3,26 @@ # Recipe:: certbot # +# op item get 'Cloudflare API token amazing-sheila' --vault Automation-Org +# op item get gk6bozl2ruh5v3knglpzsaml3u --vault Automation-Org --format json +node.run_state['boxcutter_acme'] ||= {} +node.run_state['boxcutter_acme']['certbot'] ||= {} +node.run_state['boxcutter_acme']['certbot']['cloudflare_api_token'] = \ + Boxcutter::OnePassword.op_read('op://Automation-Org/Cloudflare API token amazing-sheila/credential') + +node.default['boxcutter_acme']['certbot']['config'] = { + 'nexus' => { + 'renew_script_path' => '/opt/certbot/bin/certbot_renew.sh', + 'certbot_bin' => '/opt/certbot/venv/bin/certbot', + 'domains' => ['testy.boxcutter.net', '*.testy.boxcutter.net'], + 'email' => 'letsencrypt@boxcutter.dev', + 'cloudflare_ini' => '/etc/chef/cloudflare.ini', + 'extra_args' => [ + '--dns-cloudflare', + '--dns-cloudflare-credentials /etc/chef/cloudflare.ini', + '--test-cert', + ].join(' '), + }, +} + include_recipe 'boxcutter_acme::certbot'