Add support for LetsEncrypt-managed certs

Add support for automatic provisioning and renewal of HTTPS
certificates via LetsEncrypt. HAProxy now monitors
for new/updated certificates, executing a HAProxy reload
when the LetsEncrypt certificate is updated.

TO-DO:
1. Copying new/updated certificates to other HAProxy nodes
2. Support internal + external certificates
3. Tests

Spec is available at:
https://etherpad.opendev.org/p/kolla-ansible-letsencrypt-https

Implements: blueprint letsencrypt-https
Change-Id: I35317ea0343f0db74ddc0e587862e95408e9e106
Depends-On: https://review.opendev.org/#/c/741339
This commit is contained in:
Jason Anderson
2020-04-16 16:10:46 -05:00
committed by James Kirsch
parent 3da5cd15bd
commit 0cff76d85a
33 changed files with 502 additions and 15 deletions

View File

@@ -352,6 +352,8 @@ kibana_server_port: "5601"
kuryr_port: "23750"
letsencrypt_acme_port: "8081"
magnum_api_port: "9511"
manila_api_port: "8786"
@@ -573,6 +575,8 @@ enable_outward_rabbitmq: "{{ enable_murano | bool }}"
# with things that the clients are not aware of is generally wrong
enable_haproxy_memcached: "no"
enable_letsencrypt: no
# Additional optional OpenStack features and services are specified here
enable_aodh: "no"
enable_barbican: "no"
@@ -816,6 +820,11 @@ kolla_tls_backend_key: "{{ kolla_certificates_dir }}/backend-key.pem"
#####################
acme_client_servers: []
####################
# LetsEncrypt options
####################
letsencrypt_email:
####################
# Kibana options
####################

View File

@@ -783,3 +783,9 @@ ovn-database
[ovn-sb-db:children]
ovn-database
[letsencrypt:children]
haproxy
[letsencrypt:children]
letsencrypt

View File

@@ -801,3 +801,9 @@ ovn-database
[ovn-sb-db:children]
ovn-database
[letsencrypt:children]
haproxy
[letsencrypt:children]
letsencrypt

View File

@@ -53,6 +53,7 @@ haproxy_default_volumes:
- "{{ node_config_directory }}/haproxy/:{{ container_config_directory }}/:ro"
- "/etc/localtime:/etc/localtime:ro"
- "{{ '/etc/timezone:/etc/timezone:ro' if ansible_os_family == 'Debian' else '' }}"
- "letsencrypt_certs:/etc/letsencrypt:ro"
- "haproxy_socket:/var/lib/kolla/haproxy/"
keepalived_default_volumes:
- "{{ node_config_directory }}/keepalived/:{{ container_config_directory }}/:ro"

View File

@@ -96,15 +96,13 @@
service: "{{ haproxy_services['haproxy'] }}"
copy:
src: "{{ kolla_external_fqdn_cert }}"
dest: "{{ node_config_directory }}/haproxy/{{ item }}"
dest: "{{ node_config_directory }}/haproxy/haproxy.pem"
mode: "0660"
become: true
when:
- kolla_enable_tls_external | bool
- inventory_hostname in groups[service.group]
- service.enabled | bool
with_items:
- "haproxy.pem"
notify:
- Restart haproxy container
@@ -113,15 +111,13 @@
service: "{{ haproxy_services['haproxy'] }}"
copy:
src: "{{ kolla_internal_fqdn_cert }}"
dest: "{{ node_config_directory }}/haproxy/{{ item }}"
dest: "{{ node_config_directory }}/haproxy/haproxy-internal.pem"
mode: "0660"
become: true
when:
- kolla_enable_tls_internal | bool
- inventory_hostname in groups[service.group]
- service.enabled | bool
with_items:
- "haproxy-internal.pem"
notify:
- Restart haproxy container
@@ -146,3 +142,20 @@
- "haproxy_run.sh.j2"
notify:
- Restart haproxy container
- name: Copying new certificate monitoring script
vars:
service: "{{ haproxy_services['haproxy'] }}"
template:
src: "{{ item.src }}"
dest: "{{ node_config_directory }}/haproxy/{{ item.dest }}"
mode: "{{ item.mode | default('0660') }}"
become: true
with_items:
- { src: "check-for-new-certificates.sh.j2", dest: "check-for-new-certificates.sh", mode: "0770" }
- { src: "crontab.j2", dest: "crontab", mode: "0770" }
when:
- inventory_hostname in groups[service.group]
- service.enabled | bool
notify:
- Restart haproxy container

View File

@@ -0,0 +1,38 @@
#!/bin/bash
le_base=/etc/letsencrypt/live
for cert in haproxy.pem haproxy-internal.pem
do
if [[ "$cert" == "haproxy.pem" ]]; then
domain={{ kolla_external_fqdn }}
else
domain={{ kolla_internal_fqdn }}
fi
if [[ -d "$le_base" ]]; then
ha_cert=/etc/haproxy/$cert
le_cert=$le_base/$domain/$cert
# check if the lets encrypt certificate has been updated
if ! cmp "$ha_cert" "$le_cert" > /dev/null 2>&1 ; then
echo "Backing up $cert"
datetime="$(date '+%Y-%m-%d_%H:%M:%S')"
ha_cert_back="${ha_cert}_${datetime}"
echo "$ha_cert_back"
cp $ha_cert $ha_cert_back
echo "Updating $ha_cert"
cp $le_cert $ha_cert
ha_parent_pid=$(ps -ef | grep "haproxy_run\.sh" | awk '{print $2}')
ha_pid=$(ps --no-headers --ppid $ha_parent_pid -o pid | awk '{print $1}')
echo "Reloading HaProxy - process ${ha_pid}"
kill -USR2 $ha_pid
#TODO distribute certs to HAProxy instance on other servers
else
echo "Same certificates, not updating"
fi
fi
done

View File

@@ -0,0 +1,2 @@
* * * * * /usr/bin/check-for-new-certificates.sh >> /var/log/kolla/cron.log 2>&1
# Don't remove the empty line at the end of this file. It is required to run the cron job

View File

@@ -1,3 +1,4 @@
{% set cron_path = '/var/spool/cron/crontabs/root' if kolla_base_distro in ['ubuntu', 'debian'] else '/var/spool/cron/root' %}
{
"command": "/etc/haproxy/haproxy_run.sh",
"config_files": [
@@ -23,7 +24,7 @@
"source": "{{ container_config_directory }}/haproxy.pem",
"dest": "/etc/haproxy/haproxy.pem",
"owner": "root",
"perm": "0600",
"perm": "0700",
"optional": {{ (not kolla_enable_tls_external | bool) | string | lower }}
},
{
@@ -32,6 +33,20 @@
"owner": "root",
"perm": "0600",
"optional": {{ (not kolla_enable_tls_internal | bool) | string | lower }}
}
}{% if enable_letsencrypt | bool %},
{
"source": "{{ container_config_directory }}/check-for-new-certificates.sh",
"dest": "/usr/bin/check-for-new-certificates.sh",
"owner": "root",
"perm": "0770"
},
{
"source": "{{ container_config_directory }}/crontab",
"dest": "{{ cron_path }}",
"owner": "root",
"perm": "0600"
}{% endif %}
],
"permissions": [
]
}

View File

@@ -1,9 +1,11 @@
#!/bin/bash -x
# We need to run haproxy with one `-f` for each service, because including an
# entire config directory was not a feature until version 1.7 of HAProxy.
# So, append "-f $cfg" to the haproxy command for each service file.
# This will run haproxy_cmd *exactly once*.
find /etc/haproxy/services.d/ -mindepth 1 -print0 | \
xargs -0 -Icfg echo -f cfg | \
xargs /usr/sbin/haproxy -W -db -p /run/haproxy.pid -f /etc/haproxy/haproxy.cfg
{% if enable_letsencrypt | bool %}
echo "start cron to monitor for Let's Encrypt certificates"
{% set cron_cmd = 'cron' if kolla_base_distro in ['ubuntu', 'debian'] else 'crond' %}
{{ cron_cmd }}
{% endif %}
echo "start haproxy"
/usr/sbin/haproxy -W -db -p /run/haproxy.pid -f /etc/haproxy/haproxy.cfg -f /etc/haproxy/services.d/

View File

@@ -0,0 +1,56 @@
---
project_name: "letsencrypt"
letsencrypt_services:
letsencrypt-acme:
container_name: letsencrypt_acme
group: letsencrypt
enabled: true
image: "{{ letsencrypt_acme_image_full }}"
volumes: "{{ letsencrypt_acme_default_volumes + letsencrypt_acme_extra_volumes }}"
dimensions: "{{ letsencrypt_acme_dimensions }}"
haproxy:
letsencrypt_acme_server:
enabled: "{{ enable_letsencrypt }}"
mode: "http"
external: false
port: "{{ letsencrypt_acme_port }}"
letsencrypt-certbot:
container_name: letsencrypt_certbot
group: letsencrypt
enabled: true
image: "{{ letsencrypt_certbot_image_full }}"
volumes: "{{ letsencrypt_certbot_default_volumes + letsencrypt_certbot_extra_volumes }}"
dimensions: "{{ letsencrypt_certbot_dimensions }}"
##############
# LetsEncrypt
##############
letsencrypt_install_type: "{{ kolla_install_type }}"
letsencrypt_logging_debug: "{{ openstack_logging_debug }}"
letsencrypt_acme_image: "{{ docker_registry ~ '/' if docker_registry else '' }}{{ docker_namespace }}/{{ kolla_base_distro }}-{{ letsencrypt_install_type }}-letsencrypt"
letsencrypt_tag: "{{ openstack_tag }}"
letsencrypt_acme_image_full: "{{ letsencrypt_acme_image }}:{{ letsencrypt_tag }}"
letsencrypt_certbot_image: "{{ docker_registry ~ '/' if docker_registry else '' }}{{ docker_namespace }}/{{ kolla_base_distro }}-{{ letsencrypt_install_type }}-letsencrypt"
letsencrypt_certbot_tag: "{{ openstack_tag }}"
letsencrypt_certbot_image_full: "{{ letsencrypt_certbot_image }}:{{ letsencrypt_certbot_tag }}"
letsencrypt_acme_dimensions: "{{ default_container_dimensions }}"
letsencrypt_certbot_dimensions: "{{ default_container_dimensions }}"
letsencrypt_acme_default_volumes:
- "{{ node_config_directory }}/letsencrypt-acme/:{{ container_config_directory }}/:ro"
- "/etc/localtime:/etc/localtime:ro"
- "letsencrypt_acme_webroot:/www/data"
- "kolla_logs:/var/log/kolla/"
letsencrypt_acme_extra_volumes: "{{ default_extra_volumes }}"
letsencrypt_certbot_default_volumes:
- "{{ node_config_directory }}/letsencrypt-certbot/:{{ container_config_directory }}/:ro"
- "/etc/localtime:/etc/localtime:ro"
- "letsencrypt_certs:/etc/letsencrypt"
- "letsencrypt_acme_webroot:/www/data"
- "kolla_logs:/var/log/kolla/"
letsencrypt_certbot_extra_volumes: "{{ default_extra_volumes }}"

View File

@@ -0,0 +1,30 @@
---
- name: Restart letsencrypt-acme container
vars:
service_name: "letsencrypt-acme"
service: "{{ letsencrypt_services[service_name] }}"
become: true
kolla_docker:
action: "recreate_or_restart_container"
common_options: "{{ docker_common_options }}"
name: "{{ service.container_name }}"
image: "{{ service.image }}"
volumes: "{{ service.volumes }}"
dimensions: "{{ service.dimensions }}"
when:
- kolla_action != "config"
- name: Restart letsencrypt-certbot container
vars:
service_name: "letsencrypt-certbot"
service: "{{ letsencrypt_services[service_name] }}"
become: true
kolla_docker:
action: "recreate_or_restart_container"
common_options: "{{ docker_common_options }}"
name: "{{ service.container_name }}"
image: "{{ service.image }}"
volumes: "{{ service.volumes }}"
dimensions: "{{ service.dimensions }}"
when:
- kolla_action != "config"

View File

@@ -0,0 +1,3 @@
---
dependencies:
- { role: common }

View File

@@ -0,0 +1,16 @@
---
- name: Check LetsEncrypt containers
become: true
kolla_docker:
action: "compare_container"
common_options: "{{ docker_common_options }}"
name: "{{ item.value.container_name }}"
image: "{{ item.value.image }}"
volumes: "{{ item.value.volumes }}"
dimensions: "{{ item.value.dimensions }}"
when:
- inventory_hostname in groups[item.value.group]
- item.value.enabled | bool
with_dict: "{{ letsencrypt_services }}"
notify:
- "Restart {{ item.key }} container"

View File

@@ -0,0 +1 @@
---

View File

@@ -0,0 +1,65 @@
---
- name: Ensuring config directories exist
file:
path: "{{ node_config_directory }}/{{ item.key }}"
state: "directory"
owner: "{{ config_owner_user }}"
group: "{{ config_owner_group }}"
mode: "0770"
become: true
when:
- inventory_hostname in groups[item.value.group]
- item.value.enabled | bool
with_dict: "{{ letsencrypt_services }}"
- name: Copying over config.json files
template:
src: "{{ item.key }}.json.j2"
dest: "{{ node_config_directory }}/{{ item.key }}/config.json"
mode: "0660"
become: true
when:
- inventory_hostname in groups[item.value.group]
- item.value.enabled | bool
with_dict: "{{ letsencrypt_services }}"
notify:
- Restart {{ item.key }} container
- name: Copying files for letsencrypt-acme
vars:
letsencrypt_acme: "{{ letsencrypt_services['letsencrypt-acme'] }}"
template:
src: "{{ item.src }}"
dest: "{{ node_config_directory }}/letsencrypt-acme/{{ item.dest }}"
mode: "{{ item.mode | default('0660') }}"
become: true
with_items:
- { src: "certbot-apache.conf.j2", dest: "certbot-apache.conf" }
- { src: "apache.sh.j2", dest: "apache.sh" }
when:
- inventory_hostname in groups[letsencrypt_acme.group]
- letsencrypt_acme.enabled | bool
notify:
- Restart letsencrypt-acme container
- name: Copying files for letsencrypt-certbot
vars:
letsencrypt_certbot: "{{ letsencrypt_services['letsencrypt-certbot'] }}"
template:
src: "{{ item.src }}"
dest: "{{ node_config_directory }}/letsencrypt-certbot/{{ item.dest }}"
mode: "{{ item.mode | default('0660') }}"
become: true
with_items:
- { src: "letsencrypt.ini.j2", dest: "letsencrypt.ini" }
- { src: "certbot-renew.sh.j2", dest: "certbot-renew.sh", mode: "0770" }
- { src: "certbot.sh.j2", dest: "certbot.sh", mode: "0770" }
- { src: "crontab.j2", dest: "crontab", mode: "0770" }
when:
- inventory_hostname in groups[letsencrypt_certbot.group]
- letsencrypt_certbot.enabled | bool
notify:
- Restart letsencrypt-certbot container
- include_tasks: check-containers.yml
when: kolla_action != "config"

View File

@@ -0,0 +1,2 @@
---
- import_tasks: check-containers.yml

View File

@@ -0,0 +1,5 @@
---
- include_tasks: config.yml
- name: Flush handlers
meta: flush_handlers

View File

@@ -0,0 +1,7 @@
---
- name: "Configure haproxy for {{ project_name }}"
import_role:
role: haproxy-config
vars:
project_services: "{{ letsencrypt_services }}"
tags: always

View File

@@ -0,0 +1,2 @@
---
- include_tasks: "{{ kolla_action }}.yml"

View File

@@ -0,0 +1,18 @@
---
- name: Get container facts
become: true
kolla_container_facts:
name:
- letsencrypt_acme
register: container_facts
- name: Checking free port for LetsEncrypt server
wait_for:
host: "{{ api_interface_address }}"
port: "{{ letsencrypt_acme_port }}"
connect_timeout: 1
timeout: 1
state: stopped
when:
- container_facts['letsencrypt_acme'] is not defined
- inventory_hostname in groups['letsencrypt_acme']

View File

@@ -0,0 +1,11 @@
---
- name: Pulling LetsEncrypt images
become: true
kolla_docker:
action: "pull_image"
common_options: "{{ docker_common_options }}"
image: "{{ item.value.image }}"
when:
- inventory_hostname in groups[item.value.group]
- item.value.enabled | bool
with_dict: "{{ letsencrypt_services }}"

View File

@@ -0,0 +1,2 @@
---
- include_tasks: deploy.yml

View File

@@ -0,0 +1,6 @@
---
- import_role:
role: service-stop
vars:
project_services: "{{ letsencrypt_services }}"
service_name: "{{ project_name }}"

View File

@@ -0,0 +1,2 @@
---
- include_tasks: deploy.yml

View File

@@ -0,0 +1,14 @@
#!/bin/bash
{% set apache_binary = 'apache2' if kolla_base_distro in ['ubuntu', 'debian'] else 'httpd' %}
set -o errexit
set -o pipefail
echo "create domain directories"
mkdir -p /www/data/{{ kolla_external_fqdn }}
mkdir -p /www/data/{{ kolla_internal_fqdn }}
echo "Start apache"
/usr/sbin/{{ apache_binary }} -DFOREGROUND

View File

@@ -0,0 +1,43 @@
{% set letsencrypt_log_dir = '/var/log/kolla/letsencrypt' %}
{% set binary_path = '/usr/bin' if letsencrypt_install_type == 'binary' else '/var/lib/kolla/venv/bin' %}
Listen {{ api_interface_address | put_address_in_context('url') }}:{{ letsencrypt_acme_port }}
ServerSignature Off
ServerTokens Prod
TraceEnable off
KeepAliveTimeout {{ kolla_httpd_keep_alive }}
ErrorLog "{{ letsencrypt_log_dir }}/acme-apache-error.log"
<IfModule log_config_module>
CustomLog "{{ letsencrypt_log_dir }}/acme-apache-access.log" common
</IfModule>
{% if letsencrypt_logging_debug | bool %}
LogLevel info
{% endif %}
<Directory "/www/data/{{ kolla_external_fqdn }}">
AllowOverride None
Options None
Require all granted
</Directory>
<Directory "/www/data/{{ kolla_internal_fqdn }}">
AllowOverride None
Options None
Require all granted
</Directory>
<VirtualHost *:{{ letsencrypt_acme_port }}>
DocumentRoot "/www/data/{{ kolla_external_fqdn }}"
ServerName {{ kolla_external_fqdn }}
ErrorLog "{{ letsencrypt_log_dir }}/{{ kolla_external_fqdn }}-error_log"
TransferLog "{{ letsencrypt_log_dir }}/{{ kolla_external_fqdn }}-access_log"
</VirtualHost>
<VirtualHost *:{{ letsencrypt_acme_port }}>
DocumentRoot "/www/data/{{ kolla_internal_fqdn }}"
ServerName {{ kolla_internal_fqdn }}
ErrorLog "{{ letsencrypt_log_dir }}/{{ kolla_internal_fqdn }}-error_log"
TransferLog "{{ letsencrypt_log_dir }}/{{ kolla_internal_fqdn }}-access_log"
</VirtualHost>

View File

@@ -0,0 +1,12 @@
#!/bin/bash
certbot renew
# merging certs and keys in case they were updated
le_base=/etc/letsencrypt/live
cat "$le_base/{{ kolla_external_fqdn }}/fullchain.pem" "$le_base/{{ kolla_external_fqdn }}/privkey.pem" \
> "$le_base/{{ kolla_external_fqdn }}/haproxy.pem"
cat "$le_base/{{ kolla_internal_fqdn }}/fullchain.pem" "$le_base/{{ kolla_internal_fqdn }}/privkey.pem" \
> "$le_base/{{ kolla_internal_fqdn }}/haproxy-internal.pem"

View File

@@ -0,0 +1,27 @@
le_base=/etc/letsencrypt/live
if [ ! -f "$le_base/{{ kolla_external_fqdn }}/haproxy.pem" ]; then
echo "execute cert bot for domain: {{ kolla_external_fqdn }}"
certbot certonly -v --webroot -w /www/data/{{ kolla_external_fqdn }} --no-eff-email --agree-tos -d {{ kolla_external_fqdn }} --cert-name {{ kolla_external_fqdn }}
# create single certificate
echo "merging certs and keys"
cat "$le_base/{{ kolla_external_fqdn }}/fullchain.pem" "$le_base/{{ kolla_external_fqdn }}/privkey.pem" \
> "$le_base/{{ kolla_external_fqdn }}/haproxy.pem"
else
echo "LetsEncrypt certificate already generated for domain: {{ kolla_external_fqdn }}"
fi
if [ ! -f "$le_base/{{ kolla_internal_fqdn }}/haproxy-internal.pem" ]; then
echo "execute cert bot for domain: {{ kolla_internal_fqdn }}"
certbot certonly -v --webroot -w /www/data/{{ kolla_internal_fqdn }} --no-eff-email --agree-tos -d {{ kolla_internal_fqdn }} --cert-name {{ kolla_internal_fqdn }}
# create single certificate
echo "merging certs and keys"
cat "$le_base/{{ kolla_internal_fqdn }}/fullchain.pem" "$le_base/{{ kolla_internal_fqdn }}/privkey.pem" \
> "$le_base/{{ kolla_internal_fqdn }}/haproxy-internal.pem"
else
echo "LetsEncrypt certificate already generated for domain: {{ kolla_internal_fqdn }}"
fi
# start cron job in foreground for certificate renewal
echo "start cron"
{% set cron_cmd = 'cron -f' if kolla_base_distro in ['ubuntu', 'debian'] else 'crond -s -n' %}
{{ cron_cmd }}

View File

@@ -0,0 +1,3 @@
# Will run certbot renew every 12 hours
0 */12 * * * /usr/sbin/certbot-renew.sh >> /var/log/kolla/cron.log 2>&1
# Don't remove the empty line at the end of this file. It is required to run the cron job

View File

@@ -0,0 +1,19 @@
{% set letsencrypt_apache_dir = 'apache2/conf-enabled' if kolla_base_distro in ['ubuntu', 'debian'] else 'httpd/conf.d' %}
{
"command": "/usr/bin/apache.sh",
"config_files": [
{
"source": "{{ container_config_directory }}/certbot-apache.conf",
"dest": "/etc/{{ letsencrypt_apache_dir }}/certbot-apache.conf",
"owner": "letsencrypt",
"perm": "0600"
},
{
"source": "{{ container_config_directory }}/apache.sh",
"dest": "/usr/bin/apache.sh",
"owner": "root",
"perm": "0700"
}
],
"permissions": []
}

View File

@@ -0,0 +1,31 @@
{% set cron_path = '/var/spool/cron/crontabs/root' if kolla_base_distro in ['ubuntu', 'debian'] else '/var/spool/cron/root' %}
{
"command": "/usr/bin/certbot.sh",
"config_files": [
{
"source": "{{ container_config_directory }}/letsencrypt.ini",
"dest": "/etc/letsencrypt/cli.ini",
"owner": "root",
"perm": "0600"
},
{
"source": "{{ container_config_directory }}/certbot-renew.sh",
"dest": "/usr/bin/certbot-renew.sh",
"owner": "root",
"perm": "0700"
},
{
"source": "{{ container_config_directory }}/certbot.sh",
"dest": "/usr/bin/certbot.sh",
"owner": "root",
"perm": "0700"
},
{
"source": "{{ container_config_directory }}/crontab",
"dest": "{{ cron_path }}",
"owner": "root",
"perm": "0600"
}
],
"permissions": []
}

View File

@@ -0,0 +1,3 @@
rsa-key-size = 4096
email = {{ letsencrypt_email }}

View File

@@ -43,6 +43,7 @@
- enable_keystone_{{ enable_keystone | bool }}
- enable_kibana_{{ enable_kibana | bool }}
- enable_kuryr_{{ enable_kuryr | bool }}
- enable_letsencrypt_{{ enable_letsencrypt | bool }}
- enable_magnum_{{ enable_magnum | bool }}
- enable_manila_{{ enable_manila | bool }}
- enable_mariadb_{{ enable_mariadb | bool }}
@@ -224,6 +225,11 @@
tasks_from: loadbalancer
tags: kibana
when: enable_kibana | bool
- include_role:
name: letsencrypt
tasks_from: loadbalancer
tags: letsencrypt
when: enable_letsencrypt | bool
- include_role:
name: magnum
tasks_from: loadbalancer
@@ -1205,3 +1211,14 @@
- { role: masakari,
tags: masakari,
when: enable_masakari | bool }
- name: Apply role letsencrypt
gather_facts: false
hosts:
- letsencrypt
- '&enable_letsencrypt_True'
serial: '{{ kolla_serial|default("0") }}'
roles:
- { role: letsencrypt,
tags: letsencrypt,
when: enable_letsencrypt | bool }