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'