├── .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 | [](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 |
--------------------------------------------------------------------------------