Automating LetsEncrypt Certificates and OpenBSD’s HTTPD using Ansible
My newest side project involves the configuration of OpenBSDs HTTPD(8) to serve a clients domain via plain HTTP and via TLS using an automatically provisioned LetsEncrypt certificate.
The difficulty is that if we don’t have a certificate but we do have the config then httpd will fail to launch leaving the user in a worse situation than they were before.
The answer is to use Ansible’s stat module and liberal use of Jinja IF statements.
Task
---
- name: Ascertain if LetsEncrypt certificate exists
stat:
path: /etc/ssl/{{ clear_net_domain }}.crt
register: ssl_certificate_check
- name: Copy across plaintext/onion httpd config
template:
src: templates/httpd_singlehop.conf.j2
dest: /etc/httpd.conf
owner: root
group: wheel
mode: 0600
backup: yes
notify:
- restart httpd
- service:
name: httpd
state: started
- name: Copy across ACME config
template:
src: templates/acme-ah.conf.j2
dest: /etc/acme-ah.conf
owner: root
group: wheel
mode: 0600
- name: Enable LetsEncrypt
command: /usr/sbin/acme-client -vADf /etc/acme-ah.conf {{ clear_net_domain }}
args:
creates: /etc/ssl/{{ clear_net_domain }}.crt
notify:
- restart httpd
ignore_errors: yes
- name: Copy across ACME cron
template:
src: templates/cron/daily.local.j2
dest: /etc/daily.local
owner: root
group: wheel
mode: 0700
The very first thing we do is to check if our certificate exists or not, we then copy across a httpd configuration and an ACME configuration (see below). With the ACME configuration in place we can try and register the domain and retrieve a certificate, because there is no guarantee that our user/customer has pointed their domain at this server yet we allow the task to fail. Finally a cron job is added that renews the certificate.
httpd Template
#MACROs
ipv4_address="185.104.123.{{ ipv4_last_octet }}"
ipv6_address="2a06:3000:1000:{{ ipv6_64_subnet }}::1"
clearnet_domain="{{ clear_net_domain }}"
# HTTP instance (note the ACME location)
server "{{ clear_net_domain }}" {
listen on $ipv4_address port 80
listen on $ipv6_address port 80
no log
{% if enable_php == true %}
location "*.php" {
fastcgi socket "/run/php-fpm.sock"
}
{% else %}
#PHP disabled
{% endif %}
#ACME
location "/.well-known/acme-challenge/*" {
root "/acme"
root strip 2
}
root "/users/hosting/public_html/"
}
# An SSL instance of the website
{% if ssl_certificate_check.stat.exists == true %}
server "{{ clear_net_domain }}-ssl" {
listen on $ipv4_address tls port 443
listen on $ipv6_address tls port 443
no log
{% if enable_php == true %}
location "*.php" {
fastcgi socket "/run/php-fpm.sock"
}
{% else %}
#PHP disabled
{% endif %}
tls certificate "/etc/ssl/{{ clear_net_domain }}.fullchain.pem"
tls key "/etc/ssl/private/{{ clear_net_domain }}.key"
root "/users/hosting/public_html/"
}
{% else %}
# No SSL certificates yet
{% endif %}
# Include MIME types instead of the built-in ones
types {
include "/usr/share/misc/mime.types"
}
Within the certificate we test if our certificate exists with ssl_certificate_check.stat.exists, if it does the configuration is written out and if not a comment is left that we have no certificate.
As and when the user points their domain to this server it will already be serving their files over plain HTTP and the next time Ansible runs it will create the certificates and restart httpd to enable TLS!
One of the key elements to note is the use of the location directive in the HTTP virtual host, this is the recommended way of handling letsencrypt and is documented in the man page of ACME-CLIENT(1).