├── .gitignore ├── .travis.yml ├── .yamllint ├── LICENSE ├── README.md ├── Vagrantfile ├── defaults └── main.yml ├── files ├── 01lexicon └── run-hooks.pl ├── handlers └── main.yml ├── meta ├── .gitignore └── main.yml ├── molecule ├── Dockerfile.pdns ├── default │ ├── Dockerfile.j2 │ ├── converge.yml │ ├── molecule.yml │ ├── prepare.yml │ └── tests │ │ ├── .gitignore │ │ ├── test_dns01.py │ │ └── test_http01.py ├── lint.sh ├── pdns.conf └── setup.sh ├── tasks ├── dns-01-lexicon.yml ├── domain_config.yml ├── hooks.yml ├── main.yml ├── registration.yml └── systemd.yml └── templates ├── 90deploycert.j2 ├── certconfig.j2 ├── config.j2 ├── dehydrated.service.j2 ├── dehydrated.timer.j2 └── hooks.j2 /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.code-workspace 3 | .vagrant/ 4 | *.log 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: python 3 | services: docker 4 | dist: bionic 5 | 6 | env: 7 | - GOPATH=~/gopath 8 | 9 | install: 10 | - ./molecule/setup.sh 11 | 12 | script: 13 | - molecule test 14 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | rules: 4 | braces: 5 | max-spaces-inside: 1 6 | level: error 7 | brackets: 8 | max-spaces-inside: 1 9 | level: error 10 | line-length: disable 11 | # NOTE(retr0h): Templates no longer fail this lint rule. 12 | # Uncomment if running old Molecule templates. 13 | truthy: disable 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Alexander Zielke 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/clutterbox/ansible-dehydrated.svg?branch=master)](https://travis-ci.com/clutterbox/ansible-dehydrated) 2 | 3 | # clutterbox.dehydrated 4 | 5 | Install, configure and run dehydrated Let's Encrypt client 6 | 7 | - [clutterbox.dehydrated](#clutterboxdehydrated) 8 | * [Role Variables](#role-variables) 9 | * [Using dns-01 challenges](#using-dns-01-challenges) 10 | * [using systemd timers](#using-systemd-timers) 11 | * [Overriding per certificate config](#overriding-per-certificate-config) 12 | * [dehydrated_deploycert](#dehydrated-deploycert) 13 | + [Variables](#variables) 14 | * [Example Playbooks](#example-playbooks) 15 | + [Using http-01 .well-known/acme-challenge](#using-http-01-well-known-acme-challenge) 16 | + [Using dns-01 with cloudflare](#using-dns-01-with-cloudflare) 17 | + [Using dehydrated_deploycert with multiple certificates](#using-dehydrated-deploycert-with-multiple-certificates) 18 | * [Additinal hook scripts](#additinal-hook-scripts) 19 | + [Writing shell fragments for single hooks](#writing-shell-fragments-for-single-hooks) 20 | + [deploying complete hook script files](#deploying-complete-hook-script-files) 21 | * [Testing](#testing) 22 | * [License](#license) 23 | * [Author Information](#author-information) 24 | 25 | Table of contents generated with markdown-toc 26 | 27 | 28 | 29 | ## Role Variables 30 | 31 | Variable | Function | Default 32 | --- | --- | --- 33 | dehydrated_accept_letsencrypt_terms | Set to yes to automatically register and accept Let's Encrypt terms | no 34 | dehydrated_contactemail | E-Mail address (required) | 35 | dehydrated_account_key | If set, deploy this file containing pre-registered private key | 36 | dehydrated_domains | Content that will be written to domains.txt for obtaining certificates. See: https://github.com/dehydrated-io/dehydrated/blob/master/docs/domains_txt.md | 37 | dehydrated_deploycert | Script to run to deploy a certificate (see below) | 38 | dehydrated_wellknown | Directory where to deploy http-01 challenges | 39 | dehydrated_install_root | Where to install dehydrated | /opt/dehydrated 40 | dehydrated_update | Update dehydrated sources on ansible run | yes 41 | dehydrated_version | Which version to check out from github | HEAD 42 | dehydrated_challengetype | Challenge to use (http-01, dns-01) | http-01 43 | dehydrated_use_lexicon | Enable the use of lexicon | yes if dehydrated_challengetype == dns-01 else no 44 | dehydrated_lexicon_dns | Options for running lexicon | {} 45 | dehydrated_hooks | Dict with hook-names for which to add scripts | 46 | dehydrated_hook_scripts | Add additional scripts to hooks-Directory | [] 47 | dehydrated_key_algo | Keytype to generate (rsa, prime256v1, secp384r1) | rsa 48 | dehydrated_keysize | Size of Key (only for rsa Keys) | 4096 49 | dehydrated_ca | CA to use | https://acme-v02.api.letsencrypt.org/directory 50 | dehydrated_cronjob | Install cronjob for certificate renewals | yes 51 | dehydrated_systemd_timer | Use systemd timer for certificate renewals | no 52 | dehydrated_config_extra | Add arbitrary text to config | 53 | dehydrated_run_on_changes | If dehydrated should run if the list of domains changed | yes 54 | dehydrated_systemd_timer_onfailure | If set, an OnFailure-Directive will be added to the systemd unit | 55 | dehydrated_cert_config | Override configuration for certificates | [] 56 | dehydrated_repo_url | Specify URL to git repository of dehydrated | https://github.com/dehydrated-io/dehydrated.git 57 | dehydrated_install_pip | Whether pip will be installed when using lexicon | yes 58 | dehydrated_pip_package | Name of pip package | python3-pip if ansible is running on python3, otherwise python-pip 59 | dehydrated_pip_executable | Name of pip executable to use | autodetected by pip module 60 | 61 | ## Account registration 62 | 63 | The first time this role is used, and when `dehydrated_accept_letsencrypt_terms` is true, register with Let's Encrypt, using the value of `dehydrated_contactemail` (required). Your account details, and private key, will be created by `dehydrated` and stored in `/etc/dehydrated/accounts/` on the target system. 64 | 65 | Alternatively, if you've already setup `dehydrated` once and want to use the same account for all installations, copy your Lets' Encrypt private key (`account_key.pem`) into your ansible configuration, and set `dehydrated_account_key` to the name that file. Subsequent installations will use that key instead of registering a **new** account. 66 | 67 | **IMPORTANT** The `account_key.pem` is a private key with no passphrase. When you copy it into your Ansible configuration, make sure to use `ansible-vault` or similar to encrypt the contents of that file, at rest. If you use `ansible-vault` to encrypt it, `ansible` will automatically decrypt when referenced and installed on the target system. 68 | 69 | ## Using dns-01 challenges 70 | 71 | When `dehydrated_challengetype` is set to `dns-01`, this role will automatically install `lexicon` from python pip to be able to set and remove the necessary DNS records needed to obtain an SSL certificate. 72 | 73 | `lexicon` uses environment variables for username/token and password/secret; see examples below. 74 | 75 | ### Platforms supporting `dns-01` challenges 76 | 77 | All platforms supported by this role will work with `dns-01` challenges wherever the latest version of `lexicon` can be installed. `lexicon` is pretty aggressive about deprecating older versions of Python, and it (indirectly) relies upon the `cryptography` package which is similarly aggressive. For those who need this on older distributions, it may be possible to find specific older versions of `lexicon` and `cryptography` to install that will work on the following distributions: 78 | 79 | - Debian 8 (Jessie) 80 | - Ubuntu 16.04 (Xenial) 81 | 82 | ## using systemd timers 83 | 84 | It is possible to use a systemd-timer instead of a cronjob to renew certificates. 85 | 86 | **Note**: Enabling the systemd timer does *not* disable the cronjob. This might change in the future. 87 | 88 | ```yaml 89 | dehydrated_systemd_timer: yes 90 | dehydrated_cronjob: no 91 | ``` 92 | 93 | ## Overriding per certificate config 94 | 95 | The Configration for single certificates can be overridden using `dehydrated_cert_config`. 96 | 97 | `dehydrated_cert_config` must be a list of dicts. Only the elemenent `name:` is mandatory ans must match a certificate name. The certificate name is either the first domain listed in domains.txt or the certificate alias, if defined. 98 | 99 | Format is as follows: 100 | 101 | ```yaml 102 | dehydrated_cert_config: 103 | - name: # certificate name or alias (mandatory) 104 | state: present # present or absent (optional) 105 | challengetype: # override CHALLENGE (optional) 106 | wellknown: # override WELLKNOWN (optional) 107 | key_algo: # override KEY_ALGO (optional) 108 | keysize: # override KEYSIZE (optional) 109 | ``` 110 | 111 | ## dehydrated_deploycert 112 | 113 | The variable dehydrated_deploycert contains a shellscript fragment to be executed when a certificate has successfully been optained. This variable can either be a multiline string or a hash of multiline strings. 114 | 115 | ```yaml 116 | dehydrated_deploycert: | 117 | service nginx reload 118 | ``` 119 | 120 | In this example, for ever certificate obtained, nginx will be reloaded 121 | 122 | ```yaml 123 | dehydrated_deploycert: 124 | example.com: | 125 | service nginx reload 126 | service.example.com: | 127 | cat ${FULLCHAINFILE} ${KEYFILE} > /etc/somewhere/ssl/full.pem 128 | service someservice reload 129 | ``` 130 | 131 | Here, for certificates with the primary domain example.com, nginx will be reloaded and for service.example.com the certificate, intermediate and key will be written to another file and someservice is reloaded. 132 | 133 | ### Variables 134 | 135 | Variable | Function 136 | --- | --- 137 | DOMAIN | (Primary) Domain of the certificate 138 | KEYFILE | Full path to the keyfile 139 | CERTFILE | Full path to certificate file 140 | FULLCHAINFILE | Full path to file containing both certificate and intermediate 141 | CHAINFILE | Full path to intermediate certificate file 142 | TIMESTAMP | Timestamp when the certificate was created. 143 | 144 | ## Example Playbooks 145 | 146 | ### Using http-01 .well-known/acme-challenge 147 | 148 | ```yaml 149 | - hosts: servers 150 | vars: 151 | dehydrated_accept_letsencrypt_terms: yes 152 | dehydrated_contactemail: hostmaster@example.com 153 | dehydrated_wellknown: /var/www/example.com/.well-known/acme-challenge 154 | dehydrated_domains: | 155 | example.com 156 | dehydrated_deploycert: | 157 | service nginx reload 158 | roles: 159 | - clutterbox.dehydrated 160 | ``` 161 | 162 | ### Using dns-01 with cloudflare 163 | ```yaml 164 | - hosts: servers 165 | vars: 166 | dehydrated_accept_letsencrypt_terms: yes 167 | dehydrated_contactemail: hostmaster@example.com 168 | dehydrated_challengetype: dns-01 169 | dehydrated_lexicon_dns: 170 | LEXICON_CLOUDFLARE_USERNAME: hostmaster@example.com 171 | LEXICON_CLOUDFLARE_TOKEN: f7e7e... 172 | dehydrated_domains: | 173 | example.com 174 | dehydrated_deploycert: | 175 | service nginx reload 176 | roles: 177 | - clutterbox.dehydrated 178 | ``` 179 | 180 | ### Using dehydrated_deploycert with multiple certificates 181 | ```yaml 182 | - hosts: servers 183 | vars: 184 | # [...] 185 | dehydrated_domains: | 186 | example.com www.example.com 187 | sub.example.com 188 | service.example.com 189 | dehydrated_deploycert: 190 | example.com: | 191 | service nginx reload 192 | sub.example.com 193 | cat ${FULLCHAINFILE} ${KEYFILE} > /etc/somewhere/ssl/full.pem 194 | service someservice reload 195 | service.example.com: 196 | rsync -rl $(dirname ${KEYFILE})/ deploy@192.0.2.1:/etc/ssl/${DOMAIN}/ 197 | ssh deploy@192.0.2.1 sudo service someservice reload 198 | roles: 199 | - clutterbox.dehydrated 200 | ``` 201 | 202 | ## Additinal hook scripts 203 | 204 | This role offers two different ways to deploy additional hooks: 205 | * Using shell fragments 206 | * by deploying complete hook scripts 207 | 208 | For Information on how to use these hooks see https://github.com/lukas2511/dehydrated/blob/master/docs/examples/hook.sh 209 | 210 | This role follows the example hook script as close as possible. 211 | 212 | ### Writing shell fragments for single hooks 213 | 214 | Single hooks can be written using the `dehydrated_hooks` variable. The variable is a dict where the key is the name of a hook and the value is the shell fragment. 215 | 216 | ```yaml 217 | dehydrated_hooks: 218 | exit_hook: | 219 | echo "simple cleanup" 220 | deploy_ocsp: | 221 | cp "${OCSPFILE}" /etc/nginx/ssl/ 222 | nginx -s reload 223 | ``` 224 | 225 | For every known hook, well-know variables are set according to the example hook script (see link above). 226 | 227 | ### deploying complete hook script files 228 | 229 | Additional hooks can be deployed using `dehydrated_hook_scripts` or can be put in the /etc/dehydrated/hooks.d directory manually. 230 | 231 | The syntax for `dehydrated_hook_scripts` is as follows: 232 | 233 | ```yaml 234 | dehydrated_hook_scripts: 235 | - src: # source filename 236 | name: # optional filename inside hooks.d. defaults to filename in src 237 | state: # state present or absent. defaults to present 238 | ``` 239 | 240 | If you have a hook-script called myhook in your playbook-directory, it can be deployed like: 241 | ```yaml 242 | dehydrated_hook_scripts: 243 | - src: "{{ playbook_dir }}/myhook" 244 | ``` 245 | 246 | If you decide, that you don't need the hook anymore, you can add `state: absent` and it will be deleted. 247 | 248 | **Note:** Filenames must match ^[a-zA-Z0-9_-]+$ - otherwise they won't be executed! 249 | 250 | # Testing 251 | 252 | This role is automatically tested using Travis CI. Local testing can be done using Vagrant. Both local (Vagrant) and Travis utilize the `molecule/setup.sh` script to setup the testing environment. 253 | 254 | Multiple services are started in the environment to test both http-01 and dns-01. 255 | 256 | Service | Usage 257 | ---|--- 258 | boulder (using docker) | Let's Encrypt CA for validations 259 | nginx | webserver for http-01 260 | powerdns | Used as a nameserver for dns-01. lexicon as a plugin to manipulate records. 261 | 262 | ## Local Vagrant testing example 263 | 264 | Assuming you have Vagrant already configured, run a complete test via: 265 | 266 | vagrant up 267 | vagrant ssh 268 | source ~/venv/bin/activate 269 | cd /vagrant 270 | molecule test 271 | exit 272 | vagrant destroy 273 | 274 | # License 275 | 276 | MIT License 277 | 278 | # Author Information 279 | 280 | Alexander Zielke - mail@alexander.zielke.name 281 | -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | config.vm.box = "ubuntu/bionic64" 6 | 7 | 8 | config.vm.provider "virtualbox" do |vb| 9 | vb.linked_clone = true 10 | vb.cpus = 4 11 | vb.memory = 4096 12 | end 13 | 14 | config.vm.provision "shell-1", type: "shell", inline: <<-SHELL 15 | export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true 16 | apt-get update 17 | apt-get -y install python3-pip jq 18 | pip3 install virtualenv 19 | curl -sSL "https://github.com/docker/compose/releases/download/1.23.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose 20 | chmod +x /usr/local/bin/docker-compose 21 | SHELL 22 | config.vm.provision "docker" do |d| 23 | d.pull_images "ubuntu:18.04" 24 | d.pull_images "ubuntu:16.04" 25 | end 26 | config.vm.provision "shell-2", type: "shell", inline: <<-SHELL 27 | sudo -u vagrant -H sh -c "cd /vagrant && ./molecule/setup.sh" 28 | SHELL 29 | end 30 | -------------------------------------------------------------------------------- /defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dehydrated_dependencies: 3 | - git 4 | - openssl 5 | - curl 6 | dehydrated_repo_url: https://github.com/dehydrated-io/dehydrated.git 7 | dehydrated_install_root: /opt/dehydrated 8 | dehydrated_update: yes 9 | dehydrated_version: HEAD 10 | dehydrated_challengetype: http-01 11 | dehydrated_lexicon_dns: {} 12 | dehydrated_key_algo: rsa 13 | dehydrated_keysize: 4096 14 | dehydrated_ca: "https://acme-v02.api.letsencrypt.org/directory" 15 | dehydrated_cronjob: yes 16 | dehydrated_use_lexicon: "{{ dehydrated_challengetype == 'dns-01' }}" 17 | dehydrated_run_on_changes: yes 18 | dehydrated_systemd_timer: no 19 | dehydrated_hook_scripts: [] 20 | dehydrated_cert_config: [] 21 | # dehydrated_systemd_timer_onfailure: some_unit.service 22 | dehydrated_install_pip: yes 23 | dehydrated_pip_package: "{{ 'python3-pip' if ansible_python_version is version('3', '>=') else 'python-pip' }}" 24 | -------------------------------------------------------------------------------- /files/01lexicon: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | 4 | set -e 5 | set -u 6 | set -o pipefail 7 | 8 | export PROVIDER=${PROVIDER:-"cloudflare"} 9 | LEXICON=/usr/local/bin/lexicon 10 | 11 | function deploy_challenge { 12 | local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" 13 | echo " ++ deploy_challenge called: ${DOMAIN}" 14 | 15 | $LEXICON $PROVIDER create ${DOMAIN} TXT --name="_acme-challenge.${DOMAIN}." --content="${TOKEN_VALUE}" 16 | sleep 30 17 | } 18 | 19 | function clean_challenge { 20 | local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" 21 | echo " ++ clean_challenge called: ${DOMAIN}" 22 | 23 | $LEXICON $PROVIDER delete ${DOMAIN} TXT --name="_acme-challenge.${DOMAIN}." --content="${TOKEN_VALUE}" 24 | } 25 | 26 | HANDLER=$1; shift; 27 | if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge)$ ]]; then 28 | "$HANDLER" "$@" 29 | fi 30 | -------------------------------------------------------------------------------- /files/run-hooks.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | # 3 | # This script is a wrapper to have multiple hooks with 4 | # the dehydrated Let's Encrypt client 5 | # 6 | # Usage: 7 | # ./dehydated --hook ./dehydated-hooks.pl [other dehydrated options] 8 | # alternative via config 9 | # HOOK=/path/to/dehydrated-hooks.pl 10 | # 11 | # Remember: run-parts has certain constranints on filenames 12 | # Filenames must match ^[a-zA-Z0-9_\-]+$ 13 | 14 | use strict; 15 | use warnings; 16 | 17 | my $hooks = "/etc/dehydrated/hooks.d"; 18 | 19 | opendir(my $dh, $hooks); 20 | my @list = sort map { "$hooks/$_"; } grep { /^[0-9a-zA-Z-_]/ } readdir($dh); 21 | 22 | foreach (@list) { 23 | print " ++ running hook $_\n"; 24 | system($_, @ARGV); 25 | my $rc = $? >> 8; 26 | if ( $rc != 0 ) { 27 | exit $rc; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # This handler needs to be defined before "run dehydrated", 4 | # as handlers run in the order __defined__, not _called__. 5 | - name: update account details 6 | command: "{{ dehydrated_install_root }}/dehydrated --account" 7 | 8 | - name: run dehydrated 9 | command: "{{ dehydrated_install_root }}/dehydrated -c" 10 | when: dehydrated_run_on_changes 11 | 12 | - name: Reload systemd 13 | systemd: 14 | daemon_reload: true 15 | 16 | - name: Remove timer 17 | systemd: 18 | name: dehydrated.timer 19 | enabled: no 20 | state: stopped 21 | -------------------------------------------------------------------------------- /meta/.gitignore: -------------------------------------------------------------------------------- 1 | .galaxy_install_info 2 | -------------------------------------------------------------------------------- /meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | galaxy_info: 3 | author: Alexander Zielke 4 | namespace: clutterbox 5 | role_name: dehydrated 6 | description: Install, confgure and run dehydrated to get Let's Encrypt SSL certificates 7 | 8 | license: MIT 9 | min_ansible_version: 2.5.0 10 | platforms: 11 | - name: Ubuntu 12 | versions: 13 | - trusty 14 | - xenial 15 | - bionic 16 | - name: Debian 17 | versions: 18 | - jessie 19 | - stretch 20 | - buster 21 | galaxy_tags: [] 22 | 23 | dependencies: [] 24 | -------------------------------------------------------------------------------- /molecule/Dockerfile.pdns: -------------------------------------------------------------------------------- 1 | FROM ubuntu:bionic 2 | ADD pdns.conf /etc/powerdns/pdns.conf 3 | RUN apt-get update && \ 4 | echo "pdns-backend-sqlite3 pdns-backend-sqlite3/dbconfig-install boolean true" | debconf-set-selections && \ 5 | apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" install pdns-server pdns-backend-sqlite3 sqlite3 && \ 6 | apt-get clean 7 | 8 | EXPOSE 8081 53 53/udp 9 | CMD ["/usr/sbin/pdns_server", "--guardian=no", "--daemon=no", "--disable-syslog", "--log-timestamp=no", "--write-pid=no"] 10 | -------------------------------------------------------------------------------- /molecule/default/Dockerfile.j2: -------------------------------------------------------------------------------- 1 | # Molecule managed 2 | 3 | {% if item.registry is defined %} 4 | FROM {{ item.registry.url }}/{{ item.image }} 5 | {% else %} 6 | FROM {{ item.image }} 7 | {% endif %} 8 | 9 | RUN if [ $(command -v apt-get) ]; then apt-get update && apt-get install -y python sudo bash ca-certificates && apt-get clean; \ 10 | elif [ $(command -v dnf) ]; then dnf makecache && dnf --assumeyes install python sudo python-devel python2-dnf bash && dnf clean all; \ 11 | elif [ $(command -v yum) ]; then yum makecache fast && yum install -y python sudo yum-plugin-ovl bash && sed -i 's/plugins=0/plugins=1/g' /etc/yum.conf && yum clean all; \ 12 | elif [ $(command -v zypper) ]; then zypper refresh && zypper install -y python sudo bash python-xml && zypper clean -a; \ 13 | elif [ $(command -v apk) ]; then apk update && apk add --no-cache python sudo bash ca-certificates; \ 14 | elif [ $(command -v xbps-install) ]; then xbps-install -Syu && xbps-install -y python sudo bash ca-certificates && xbps-remove -O; fi 15 | -------------------------------------------------------------------------------- /molecule/default/converge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Converge 3 | hosts: all 4 | roles: 5 | - role: clutterbox.dehydrated 6 | -------------------------------------------------------------------------------- /molecule/default/molecule.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependency: 3 | name: galaxy 4 | driver: 5 | name: docker 6 | platforms: 7 | - name: ubuntu1804-http01 8 | image: ubuntu:18.04 9 | groups: [http01] 10 | volumes: 11 | - "/tmp/www:/tmp/www" 12 | - name: ubuntu1604-http01 13 | image: ubuntu:16.04 14 | groups: [http01] 15 | volumes: 16 | - "/tmp/www:/tmp/www" 17 | - name: debian8-http01 18 | image: debian:8 19 | groups: [http01] 20 | volumes: 21 | - "/tmp/www:/tmp/www" 22 | - name: debian9-http01 23 | image: debian:9 24 | groups: [http01] 25 | volumes: 26 | - "/tmp/www:/tmp/www" 27 | - name: debian10-http01 28 | image: debian:10 29 | groups: [http01] 30 | volumes: 31 | - "/tmp/www:/tmp/www" 32 | - name: ubuntu1804-dns01 33 | image: ubuntu:18.04 34 | groups: [dns01] 35 | # - name: ubuntu1604-dns01 36 | # image: ubuntu:16.04 37 | # groups: [dns01] 38 | # - name: debian8-dns01 39 | # image: debian:8 40 | # groups: [dns01] 41 | - name: debian9-dns01 42 | image: debian:9 43 | groups: [dns01] 44 | - name: debian10-dns01 45 | image: debian:10 46 | groups: [dns01] 47 | provisioner: 48 | name: ansible 49 | inventory: 50 | group_vars: 51 | http01: 52 | dehydrated_contactemail: notused@le2.wtf 53 | dehydrated_accept_letsencrypt_terms: true 54 | dehydrated_domains: | 55 | le2.wtf 56 | dehydrated_wellknown: /tmp/www/.well-known/acme-challenge 57 | dehydrated_use_lexicon: false 58 | dehydrated_ca: http://10.77.77.1:4001/directory 59 | dehydrated_cronjob: false 60 | dns01: 61 | dehydrated_contactemail: notused@le3.wtf 62 | dehydrated_accept_letsencrypt_terms: true 63 | dehydrated_domains: | 64 | le3.wtf 65 | dehydrated_challengetype: dns-01 66 | dehydrated_ca: http://10.77.77.1:4001/directory 67 | dehydrated_cronjob: false 68 | dehydrated_wellknown: /tmp/www/.well-known/acme-challenge 69 | dehydrated_lexicon_dns: 70 | PROVIDER: powerdns 71 | LEXICON_POWERDNS_PDNS_SERVER: http://10.77.77.1:8081 72 | LEXICON_POWERDNS_PDNS_SERVER_ID: localhost 73 | LEXICON_POWERDNS_TOKEN: dummy 74 | scenario: 75 | name: default 76 | verifier: 77 | name: testinfra 78 | lint: 'molecule/lint.sh' 79 | -------------------------------------------------------------------------------- /molecule/default/prepare.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Prepare 3 | hosts: all 4 | tasks: 5 | - name: Install cron for molecule based role testing 6 | package: 7 | name: cron 8 | state: present 9 | -------------------------------------------------------------------------------- /molecule/default/tests/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | -------------------------------------------------------------------------------- /molecule/default/tests/test_dns01.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import testinfra.utils.ansible_runner 4 | 5 | testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner( 6 | os.environ['MOLECULE_INVENTORY_FILE']).get_hosts('dns01') 7 | 8 | 9 | def test_certificate_file_exists(host): 10 | cert = host.file('/etc/dehydrated/certs/le3.wtf/cert.pem') 11 | chain = host.file('/etc/dehydrated/certs/le3.wtf/chain.pem') 12 | fullchain = host.file('/etc/dehydrated/certs/le3.wtf/fullchain.pem') 13 | privkey = host.file('/etc/dehydrated/certs/le3.wtf/privkey.pem') 14 | 15 | assert cert.exists 16 | assert chain.exists 17 | assert fullchain.exists 18 | assert privkey.exists 19 | -------------------------------------------------------------------------------- /molecule/default/tests/test_http01.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import testinfra.utils.ansible_runner 4 | 5 | testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner( 6 | os.environ['MOLECULE_INVENTORY_FILE']).get_hosts('http01') 7 | 8 | 9 | def test_certificate_file_exists(host): 10 | cert = host.file('/etc/dehydrated/certs/le2.wtf/cert.pem') 11 | chain = host.file('/etc/dehydrated/certs/le2.wtf/chain.pem') 12 | fullchain = host.file('/etc/dehydrated/certs/le2.wtf/fullchain.pem') 13 | privkey = host.file('/etc/dehydrated/certs/le2.wtf/privkey.pem') 14 | 15 | assert cert.exists 16 | assert chain.exists 17 | assert fullchain.exists 18 | assert privkey.exists 19 | -------------------------------------------------------------------------------- /molecule/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # accumulate error codes, allowing all linters to execute. 3 | # exit with total of all error codes at end. 4 | 5 | declare -i errors 6 | catch() { errors=$errors+$?; } 7 | trap catch ERR 8 | onexit() { exit $errors; } 9 | trap onexit EXIT 10 | 11 | #set -x 12 | 13 | yamllint . 14 | ansible-lint 15 | flake8 16 | -------------------------------------------------------------------------------- /molecule/pdns.conf: -------------------------------------------------------------------------------- 1 | api=yes 2 | api-key=dummy 3 | include-dir=/etc/powerdns/pdns.d 4 | launch= 5 | local-address=0.0.0.0 6 | local-port=53 7 | local-ipv6= 8 | security-poll-suffix= 9 | setgid=pdns 10 | setuid=pdns 11 | webserver=yes 12 | webserver-address=0.0.0.0 13 | webserver-allow-from=0.0.0.0/0 14 | webserver-port=8081 15 | -------------------------------------------------------------------------------- /molecule/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # NOTE: this assumes we are running in Travis or Vagrant, 4 | # so happily installs things globally, and with abandon. 5 | 6 | set -eo pipefail 7 | 8 | # if running in vagrant, `/vagrant` exists; 9 | # Then, setup local virtualenv and use it. 10 | if [ -d /vagrant ]; then 11 | (cd ~; virtualenv -p python3 venv) 12 | source ~/venv/bin/activate 13 | fi 14 | 15 | # Install molecule 16 | pip install "molecule[ansible,docker,lint]" testinfra docker 17 | 18 | # Install linting tools 19 | pip install yamllint ansible-lint flake8 20 | 21 | # Let's Encrypt CA (boulder) 22 | export GOPATH=~/gopath 23 | mkdir -p $GOPATH 24 | git clone https://github.com/letsencrypt/boulder/ $GOPATH/src/github.com/letsencrypt/boulder 25 | cd $GOPATH/src/github.com/letsencrypt/boulder 26 | for f in va.json va-remote-a.json va-remote-b.json; do 27 | jq '.va.dnsResolvers = ["10.77.77.1:53"]' test/config/$f > test/config/$f.new 28 | mv test/config/$f.new test/config/$f 29 | done 30 | docker-compose up -d 31 | until curl -s http://127.0.0.1:4001/directory; do sleep 0.5; done 32 | cd - 33 | 34 | # nginx for http-01 challenges 35 | mkdir -p /tmp/www/.well-known/acme-challenge 36 | docker run -d -v /tmp/www:/usr/share/nginx/html:ro -p 10.77.77.1:5002:80 nginx 37 | 38 | # powerdns for dns-01 challenges 39 | docker build -t pdns -f molecule/Dockerfile.pdns molecule/ 40 | docker run -d -p 10.77.77.1:53:53/udp -p 10.77.77.1:53:53 -p 10.77.77.1:8081:8081 pdns 41 | 42 | # create example.com dummy zone for http-01 43 | curl -v -H 'X-API-Key: dummy' -X POST http://10.77.77.1:8081/api/v1/servers/localhost/zones \ 44 | -d '{ "name": "le2.wtf.", "kind": "Native", "nameservers": ["localhost."] }' 45 | curl -v -H 'X-API-Key: dummy' -X PATCH http://10.77.77.1:8081/api/v1/servers/localhost/zones/le2.wtf. \ 46 | -d '{"rrsets": [ 47 | {"name": "le2.wtf.", "type": "A", "ttl": 60, "changetype": "REPLACE", "records": [ 48 | {"content": "10.77.77.1", "disabled": false} 49 | ]} 50 | ]}' 51 | 52 | curl -v -H 'X-API-Key: dummy' -X POST http://10.77.77.1:8081/api/v1/servers/localhost/zones \ 53 | -d '{ "name": "le3.wtf.", "kind": "Native", "nameservers": ["localhost."] }' 54 | 55 | echo "Environment setup done!" 56 | -------------------------------------------------------------------------------- /tasks/dns-01-lexicon.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Ensure python-pip is installed 3 | apt: 4 | name: "{{ dehydrated_pip_package }}" 5 | when: dehydrated_install_pip 6 | 7 | - name: Install dns-lexicon 8 | pip: 9 | name: dns-lexicon 10 | executable: "{{ dehydrated_pip_executable|default(omit) }}" 11 | 12 | - name: Copy hook script 13 | copy: 14 | dest: /etc/dehydrated/hooks.d/01lexicon 15 | src: 01lexicon 16 | owner: root 17 | group: root 18 | mode: "0700" 19 | -------------------------------------------------------------------------------- /tasks/domain_config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Ensure certificate directory exists 3 | file: 4 | path: "/etc/dehydrated/certs/{{ item.name }}" 5 | state: directory 6 | owner: root 7 | group: root 8 | mode: 0700 9 | loop: "{{ dehydrated_cert_config }}" 10 | 11 | - name: Generate per certificate configs 12 | template: 13 | dest: "/etc/dehydrated/certs/{{ item.name }}/config" 14 | src: certconfig.j2 15 | owner: root 16 | group: root 17 | mode: 0600 18 | loop: "{{ dehydrated_cert_config }}" 19 | when: item.state|default('present') == "present" 20 | notify: run dehydrated 21 | 22 | - name: Remove per certificate configs 23 | file: 24 | path: "/etc/dehydrated/certs/{{ item.name }}/config" 25 | state: absent 26 | loop: "{{ dehydrated_cert_config }}" 27 | when: item.state|default('present') == "absent" 28 | notify: run dehydrated 29 | -------------------------------------------------------------------------------- /tasks/hooks.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Copy run-hooks wrapper 3 | copy: 4 | dest: /etc/dehydrated/run-hooks.pl 5 | src: run-hooks.pl 6 | owner: root 7 | group: root 8 | mode: 0700 9 | 10 | - name: Create hooks.d 11 | file: 12 | dest: /etc/dehydrated/hooks.d 13 | state: directory 14 | owner: root 15 | group: root 16 | mode: 0700 17 | 18 | - name: Generate 90deploycert hook 19 | template: 20 | src: 90deploycert.j2 21 | dest: /etc/dehydrated/hooks.d/90deploycert 22 | owner: root 23 | group: root 24 | mode: "0700" 25 | when: dehydrated_deploycert is defined 26 | 27 | - name: Remove 90deploycert hook 28 | file: dest=/etc/dehydrated/hooks.d/90deploycert state=absent 29 | when: dehydrated_deploycert is not defined 30 | 31 | - name: Deploy script with custom hooks 32 | template: 33 | src: hooks.j2 34 | dest: /etc/dehydrated/hooks.d/50customhooks 35 | owner: root 36 | group: root 37 | mode: "0700" 38 | when: dehydrated_hooks is defined 39 | 40 | - name: Remove script with custom hooks 41 | file: dest=/etc/dehydrated/hooks.d/50customhooks state=absent 42 | when: dehydrated_hooks is not defined 43 | 44 | - name: Deploy custom hooks 45 | copy: 46 | src: "{{ item.src }}" 47 | dest: "/etc/dehydrated/hooks.d/{{ item.name|default(item.src|basename) }}" 48 | owner: root 49 | group: root 50 | mode: 0700 51 | when: item.state|default('present') == "present" 52 | loop: "{{ dehydrated_hook_scripts }}" 53 | 54 | - name: Remove custom hooks with state absent 55 | file: 56 | path: "/etc/dehydrated/hooks.d/{{ item.name|default(item.src|basename) }}" 57 | state: absent 58 | when: item.state|default('present') == "absent" 59 | loop: "{{ dehydrated_hook_scripts }}" 60 | -------------------------------------------------------------------------------- /tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install dehydrated dependencies 3 | apt: name={{ dehydrated_dependencies }} 4 | 5 | - name: Checkout dehydrated from github 6 | git: 7 | repo: "{{ dehydrated_repo_url }}" 8 | update: "{{ dehydrated_update }}" 9 | dest: "{{ dehydrated_install_root }}" 10 | version: "{{ dehydrated_version }}" 11 | 12 | - name: Create /etc/dehydrated 13 | file: dest=/etc/dehydrated state=directory owner=root group=root mode=0700 14 | 15 | - name: Create wellknown challenge directory 16 | file: 17 | path: "{{ dehydrated_wellknown }}" 18 | state: directory 19 | owner: root 20 | group: root 21 | mode: 0755 22 | when: dehydrated_wellknown is defined 23 | 24 | - name: Generate dehydrated config 25 | template: 26 | dest: /etc/dehydrated/config 27 | src: config.j2 28 | owner: root 29 | group: root 30 | mode: 0600 31 | 32 | - name: Generate dehydrated domains.txt 33 | copy: 34 | dest: /etc/dehydrated/domains.txt 35 | content: "{{ dehydrated_domains }}" 36 | owner: root 37 | group: root 38 | mode: 0600 39 | notify: run dehydrated 40 | 41 | - import_tasks: hooks.yml 42 | 43 | - import_tasks: domain_config.yml 44 | 45 | - name: Include dns-01-lexicon.yml 46 | include_tasks: dns-01-lexicon.yml 47 | when: 48 | - dehydrated_use_lexicon 49 | 50 | - name: Install cronjob 51 | cron: 52 | name: dehydrated-renew 53 | minute: "{{ 59|random(seed=inventory_hostname) }}" 54 | hour: "{{ 4|random(seed=inventory_hostname) }}" 55 | user: root 56 | job: "{{ dehydrated_install_root }}/dehydrated -c" 57 | cron_file: dehydrated 58 | state: "{{ 'present' if dehydrated_cronjob else 'absent' }}" 59 | 60 | - import_tasks: systemd.yml 61 | 62 | - import_tasks: registration.yml 63 | 64 | - name: Flush handler to force dehydrated run now if neccessary 65 | meta: flush_handlers 66 | -------------------------------------------------------------------------------- /tasks/registration.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Determine CA account key file location 4 | set_fact: 5 | ca_account_key_file: "/etc/dehydrated/accounts/{{ ((dehydrated_ca + '\n')|b64encode).rstrip('=').replace('+', '-').replace('/', '_') }}/account_key.pem" 6 | 7 | - name: Create CA account directory 8 | file: dest="{{ ca_account_key_file | dirname }}" state=directory owner=root group=root mode=0700 9 | when: dehydrated_account_key is defined 10 | 11 | - name: Deploy CA account key 12 | copy: 13 | src: "{{ dehydrated_account_key }}" 14 | dest: "{{ ca_account_key_file }}" 15 | owner: root 16 | group: root 17 | mode: 0600 18 | when: dehydrated_account_key is defined 19 | notify: update account details 20 | 21 | - name: Check if already registered 22 | stat: 23 | path: "{{ ca_account_key_file }}" 24 | register: ca_stat 25 | 26 | - block: 27 | - name: "assert dehydrated_accept_letsencrypt_terms is true" 28 | assert: 29 | that: dehydrated_accept_letsencrypt_terms 30 | 31 | - name: Register to CA 32 | command: "{{ dehydrated_install_root }}/dehydrated --register --accept-terms" 33 | # \end block register 34 | when: "not ca_stat.stat.exists or (ca_stat.stat.isreg is defined and not ca_stat.stat.isreg)" 35 | -------------------------------------------------------------------------------- /tasks/systemd.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Upload systemd service files 4 | template: 5 | src: "{{ item }}.j2" 6 | dest: /etc/systemd/system/{{ item }} 7 | mode: 0644 8 | loop: 9 | - dehydrated.service 10 | - dehydrated.timer 11 | when: dehydrated_systemd_timer 12 | notify: Reload systemd 13 | 14 | - name: Remove system service files 15 | file: 16 | path: /etc/systemd/system/{{ item }} 17 | state: absent 18 | loop: 19 | - dehydrated.service 20 | - dehydrated.timer 21 | when: not dehydrated_systemd_timer 22 | notify: Remove timer 23 | 24 | - name: Activate systemd timer 25 | systemd: 26 | daemon_reload: yes 27 | name: dehydrated.timer 28 | enabled: yes 29 | state: started 30 | when: dehydrated_systemd_timer 31 | -------------------------------------------------------------------------------- /templates/90deploycert.j2: -------------------------------------------------------------------------------- 1 | #jinja2: trim_blocks: True, lstrip_blocks: True 2 | #!/usr/bin/env bash 3 | 4 | set -e 5 | set -u 6 | set -o pipefail 7 | 8 | deploy_cert() { 9 | local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" 10 | 11 | {% if dehydrated_deploycert is string %} 12 | {{ dehydrated_deploycert }} 13 | {% else %} 14 | {% for domain, script in dehydrated_deploycert.items() %} 15 | if [[ "${DOMAIN}" = "{{ domain }}" ]]; then 16 | {{ script }} 17 | fi 18 | {% endfor %} 19 | {% endif %} 20 | } 21 | 22 | HANDLER="$1"; shift 23 | if [[ "${HANDLER}" =~ ^(deploy_cert)$ ]]; then 24 | "$HANDLER" "$@" 25 | fi 26 | -------------------------------------------------------------------------------- /templates/certconfig.j2: -------------------------------------------------------------------------------- 1 | #jinja2: trim_blocks: True, lstrip_blocks: True 2 | {% if item.challengetype is defined %}CHALLENGETYPE={{ item.challengetype }}{% endif %} 3 | {% if item.key_algo is defined %}KEY_ALGO={{ item.key_algo }}{% endif %} 4 | {% if item.keysize is defined %}KEYSIZE={{ item.keysize }}{% endif %} 5 | {% if item.wellknown is defined %}WELLKNOWN={{ item.wellknown }}{% endif %} 6 | -------------------------------------------------------------------------------- /templates/config.j2: -------------------------------------------------------------------------------- 1 | #jinja2: trim_blocks: True, lstrip_blocks: True 2 | CA="{{ dehydrated_ca }}" 3 | CHALLENGETYPE="{{ dehydrated_challengetype }}" 4 | CONTACT_EMAIL="{{ dehydrated_contactemail | mandatory }}" 5 | KEY_ALGO={{ dehydrated_key_algo }} 6 | KEYSIZE={{ dehydrated_keysize }} 7 | {% if dehydrated_wellknown is defined %} 8 | WELLKNOWN={{ dehydrated_wellknown }} 9 | {% endif %} 10 | HOOK=/etc/dehydrated/run-hooks.pl 11 | {% if dehydrated_challengetype == "dns-01" %} 12 | {% for k, v in dehydrated_lexicon_dns.items() %} 13 | export {{ k }}={{ v }} 14 | {% endfor %} 15 | {% endif %} 16 | {{ dehydrated_config_extra|default("") }} 17 | -------------------------------------------------------------------------------- /templates/dehydrated.service.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ACME Cert renewal 3 | ConditionFileNotEmpty=/etc/dehydrated/domains.txt 4 | {% if dehydrated_systemd_timer_onfailure is defined %} 5 | OnFailure={{ dehydrated_systemd_timer_onfailure }} 6 | {% endif %} 7 | 8 | [Service] 9 | User=root 10 | ExecStart={{ dehydrated_install_root }}/dehydrated --cron 11 | Type=oneshot 12 | -------------------------------------------------------------------------------- /templates/dehydrated.timer.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ACME Cert renewal timer 3 | 4 | [Timer] 5 | OnCalendar=*-*-* {{ 4|random(seed=inventory_hostname) }}:{{ 59|random(seed=inventory_hostname) }}:00 6 | Persistent=true 7 | RandomizedDelaySec=300 8 | 9 | [Install] 10 | WantedBy=timers.target 11 | -------------------------------------------------------------------------------- /templates/hooks.j2: -------------------------------------------------------------------------------- 1 | #jinja2: trim_blocks: True, lstrip_blocks: True 2 | #!/usr/bin/env bash 3 | {% for hook, script in dehydrated_hooks.items() %} 4 | {{ hook }}() { 5 | {% if hook == "deploy_challenge" %} 6 | local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" 7 | {% elif hook == "clean_challenge" %} 8 | local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}" 9 | {% elif hook == "deploy_cert" %} 10 | local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}" 11 | {% elif hook == "deploy_ocsp" %} 12 | local DOMAIN="${1}" OCSPFILE="${2}" TIMESTAMP="${3}" 13 | {% elif hook == "unchanged_cert" %} 14 | local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" 15 | {% elif hook == "invalid_challenge" %} 16 | local DOMAIN="${1}" RESPONSE="${2}" 17 | {% elif hook == "request_failure" %} 18 | local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}" HEADERS="${4}" 19 | {% elif hook == "generate_csr" %} 20 | local DOMAIN="${1}" CERTDIR="${2}" ALTNAMES="${3}" 21 | {% elif hook == "startup_hook" %} 22 | {% elif hook == "exit_hook" %} 23 | {% endif %} 24 | {{ script }} 25 | } 26 | {% endfor %} 27 | 28 | 29 | HANDLER="$1"; shift 30 | if [[ "${HANDLER}" =~ ^({{ dehydrated_hooks.keys()|join('|') }})$ ]]; then 31 | "$HANDLER" "$@" 32 | fi 33 | --------------------------------------------------------------------------------