24 April 2017

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).