├── .gitattributes ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── defaults └── main.yml ├── meta └── main.yml ├── tasks └── main.yml ├── templates └── openssl-request.conf.j2 └── tests ├── inventory └── test.yml /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.retry 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: python 3 | python: "2.7" 4 | 5 | env: 6 | - TESTBOOK=test.yml 7 | 8 | before_install: 9 | - sudo apt-get update -qq 10 | 11 | install: 12 | # Install Ansible. 13 | - pip install ansible 14 | 15 | # Add ansible.cfg to pick up roles path. 16 | - "{ echo '[defaults]'; echo 'roles_path = ../'; } >> ansible.cfg" 17 | 18 | # Create skeleton files required for role to function 19 | - mkdir /tmp/cert_test 20 | - mkdir /tmp/cert_test/letsencrypt 21 | - touch /tmp/cert_test/localhost.key 22 | - touch /tmp/cert_test/localhost.csr 23 | - touch /tmp/cert_test/localhost.crt 24 | - touch /tmp/cert_test/letsencrypt/letsencrypt_account.key 25 | 26 | script: 27 | # Check the role/playbook's syntax. 28 | - "ansible-playbook -i tests/inventory tests/$TESTBOOK --syntax-check" 29 | # Run role and ensure it completes successfully. 30 | - "ansible-playbook -i tests/inventory tests/$TESTBOOK --skip-tags web-api" 31 | # Run role again and check for idempotence. 32 | - "ansible-playbook -i tests/inventory tests/$TESTBOOK --skip-tags web-api | grep -q 'changed=0.*failed=0' && (echo 'Idempotence test: pass' && exit 0) || (echo 'Idempotence test: fail' && exit 1)" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ben Dews 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/bendews/ansible-letsencrypt-cloudflare.svg?branch=master)](https://travis-ci.org/bendews/ansible-letsencrypt-cloudflare) 2 | 3 | # LetsEncrypt-Cloudflare 4 | 5 | This role simplifies the process of renewing LetsEncrypt Certificates when utilising Cloudflare as a DNS provider. 6 | 7 | ## Requirements 8 | 9 | - Python >= 2.6 10 | - OpenSSL (will automatically be installed if supported) 11 | 12 | ## Role Variables 13 | Available variables are listed below, along with default values (see `defaults/main.yml` for many more variables that can be modified) 14 | 15 | ### Required Fields: 16 | 17 | letsencrypt_email: "" 18 | cloudflare_email: "" 19 | cloudflare_api_key: "" # 'Global' API key for specified Cloudflare account 20 | cloudflare_domain: "" # Cloudflare hosted DNS zone for entries to be created under 21 | 22 | ### Important Notes: 23 | By default, the role will use the inventory hostname as the Common Name to request a certificate, and place all generated/recieved certificate files in `/etc/ssl/[Certificate Common Name]`, and all LetsEncrypt account files in `/etc/ssl/lets_encrypt`. These paths can all be overridden (see `defaults/main.yml`). 24 | 25 | Furthermore, the certificate files can also be copied and renamed to another location after generation, by modifying any of the following variables: 26 | 27 | copy_csr_full_path: "" 28 | copy_crt_full_path: "" 29 | copy_key_full_path: "" 30 | copy_intermediate_full_path: "" # Requires include_intermediate set to 'yes' 31 | copy_fullchain_full_path: "" # Requires include_intermediate set to 'yes' 32 | 33 | The role also defaults to using the "staging" letsencrypt endpoints which will generate functionally correct yet untrusted certificates. In order to generate valid certificates, set: 34 | 35 | letsencrypt_production: yes 36 | 37 | 38 | Generated files can be removed after use by specifying the following variable: 39 | 40 | cleanup_all: yes 41 | 42 | # Example Playbook 43 | 44 | - hosts: servers 45 | tasks: 46 | - name: Renew/Download new SSL certificates 47 | include_role: 48 | name: letsencrypt-cloudflare 49 | vars: 50 | letsencrypt_email: "a@abc.com" 51 | cloudflare_email: "a@abc.com" 52 | cloudflare_domain: "abc.com" 53 | cloudflare_api_key: "AAABBBCCCDDDEEE111222333" 54 | letsencrypt_production: yes 55 | include_intermediate: yes 56 | 57 | 58 | # TODO: 59 | 60 | - Add support for multiple CN's 61 | 62 | # License 63 | 64 | MIT 65 | 66 | # Author Information 67 | 68 | Created in 2017 by [Ben Dews](bendews.com) -------------------------------------------------------------------------------- /defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | certificate_common_name: "{{ inventory_hostname }}" 3 | certificate_remaining_days: 10 4 | certificate_sans: [] 5 | certificate_directory: "/etc/ssl/{{ certificate_common_name }}" 6 | certificate_files_mode: 0600 7 | certificate_files_owner: root 8 | certificate_files_group: root 9 | 10 | csr_filename: "{{ certificate_common_name }}.csr" 11 | crt_filename: "{{ certificate_common_name }}.crt" 12 | key_filename: "{{ certificate_common_name }}.key" 13 | key_size: 2048 14 | 15 | copy_csr_full_path: "" 16 | copy_crt_full_path: "" 17 | copy_key_full_path: "" 18 | copy_intermediate_full_path: "" 19 | copy_fullchain_full_path: "" 20 | 21 | letsencrypt_email: "" 22 | letsencrypt_key_size: 2048 23 | letsencrypt_key_directory: /etc/ssl/lets_encrypt 24 | letsencrypt_key_filename: letsencrypt_account.key 25 | letsencrypt_production: no 26 | letsencrypt_acme_directory: "{{ 'https://acme-v02.api.letsencrypt.org/directory' if letsencrypt_production else 'https://acme-staging-v02.api.letsencrypt.org/directory' }}" 27 | letsencrypt_acme_version: 2 28 | 29 | 30 | include_intermediate: no 31 | intermediate_filename: "{{ certificate_common_name }}.intermediate.crt" 32 | fullchain_filename: "{{ certificate_common_name }}.fullchain.pem" 33 | 34 | cloudflare_email: "" 35 | cloudflare_api_key: "" 36 | cloudflare_domain: "" 37 | 38 | cleanup_all: no 39 | -------------------------------------------------------------------------------- /meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependencies: [] 3 | 4 | galaxy_info: 5 | author: Ben Dews 6 | description: LetsEncrypt renewal using Cloudflare DNS 7 | license: MIT 8 | min_ansible_version: 2.2 9 | 10 | platforms: 11 | - name: EL 12 | versions: 13 | - 6 14 | - 7 15 | - name: Debian 16 | versions: 17 | - all 18 | - name: Ubuntu 19 | versions: 20 | - all 21 | 22 | galaxy_tags: 23 | - web 24 | - ssl 25 | - letsencrypt 26 | - cloudflare 27 | - https -------------------------------------------------------------------------------- /tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install OpenSSL 3 | package: 4 | name: openssl 5 | state: latest 6 | 7 | - name: check if the certificate directory exists 8 | stat: 9 | path: "{{ certificate_directory }}" 10 | register: certificate_directory_stat 11 | 12 | - name: "create the {{ certificate_directory }} directory" 13 | file: 14 | path: "{{ certificate_directory }}" 15 | state: directory 16 | owner: "{{ certificate_files_owner }}" 17 | mode: 0755 18 | 19 | - name: generate the private key 20 | command: "openssl genrsa -out \"{{ certificate_directory + '/' + key_filename }}\" {{ key_size }}" 21 | args: 22 | creates: "{{ certificate_directory + '/' + key_filename }}" 23 | 24 | - name: set the private key file permissions 25 | file: 26 | path: "{{ certificate_directory + '/' + key_filename }}" 27 | owner: "{{ certificate_files_owner }}" 28 | group: "{{ certificate_files_group }}" 29 | mode: "{{ certificate_files_mode }}" 30 | 31 | - name: check if the CSR exists 32 | stat: 33 | path: "{{ certificate_directory + '/' + csr_filename }}" 34 | register: csr_stat 35 | 36 | - name: create the OpenSSL configuration file for the CSR 37 | template: 38 | src: openssl-request.conf.j2 39 | dest: "{{ certificate_directory }}/openssl-request.conf" 40 | owner: "{{ certificate_files_owner }}" 41 | mode: "{{ certificate_files_mode }}" 42 | when: not csr_stat.stat.exists 43 | 44 | - name: generate the CSR 45 | command: | 46 | openssl req -new -sha256 -subj "/CN={{ certificate_common_name }}" 47 | -config "{{ certificate_directory }}/openssl-request.conf" 48 | -key "{{ certificate_directory + '/' + key_filename }}" 49 | -out "{{ certificate_directory + '/' + csr_filename }}" 50 | args: 51 | creates: "{{ certificate_directory + '/' + csr_filename }}" 52 | 53 | - name: set the CSR file permissions 54 | file: 55 | path: "{{ certificate_directory + '/' + csr_filename }}" 56 | owner: "{{ certificate_files_owner }}" 57 | group: "{{ certificate_files_group }}" 58 | mode: "{{ certificate_files_mode }}" 59 | 60 | - name: delete the OpenSSL configuration file for the CSR 61 | file: 62 | path: "{{ certificate_directory }}/openssl-request.conf" 63 | state: absent 64 | 65 | - name: "create the {{ letsencrypt_key_directory }} directory" 66 | file: 67 | path: "{{ letsencrypt_key_directory }}" 68 | owner: "{{ certificate_files_owner }}" 69 | mode: 0700 70 | state: directory 71 | 72 | - name: generate the Let's Encrypt account key 73 | command: "openssl genrsa -out \"{{ letsencrypt_key_directory + '/' + letsencrypt_key_filename }}\" {{ letsencrypt_key_size }}" 74 | args: 75 | creates: "{{ letsencrypt_key_directory + '/' + letsencrypt_key_filename }}" 76 | 77 | - name: set the Let's Encrypt account key file permissions 78 | file: 79 | path: "{{ letsencrypt_key_directory + '/' + letsencrypt_key_filename }}" 80 | owner: "{{ certificate_files_owner }}" 81 | mode: "{{ certificate_files_mode }}" 82 | 83 | - name: initiate the Let's Encrypt challenge 84 | acme_certificate: 85 | acme_directory: "{{ letsencrypt_acme_directory }}" 86 | acme_version: "{{ letsencrypt_acme_version }}" 87 | challenge: dns-01 88 | account_key: "{{ letsencrypt_key_directory + '/' + letsencrypt_key_filename }}" 89 | csr: "{{ certificate_directory + '/' + csr_filename }}" 90 | dest: "{{ certificate_directory + '/' + crt_filename }}" 91 | account_email: "{{ letsencrypt_email }}" 92 | remaining_days: "{{ certificate_remaining_days }}" 93 | terms_agreed: yes 94 | register: letsencrypt_challenge 95 | tags: 96 | - web-api 97 | 98 | - name: Create DNS Record 99 | cloudflare_dns: 100 | domain: "{{ cloudflare_domain }}" 101 | record: "_acme-challenge.{{ item.key }}" 102 | type: TXT 103 | value: "\"{{ item.value['dns-01']['resource_value'] }}\"" 104 | state: present 105 | solo: true 106 | account_email: "{{ cloudflare_email }}" 107 | account_api_token: "{{ cloudflare_api_key }}" 108 | with_dict: "{{ letsencrypt_challenge['challenge_data'] | default({}) }}" 109 | when: letsencrypt_challenge['challenge_data'] is defined 110 | tags: 111 | - web-api 112 | 113 | - name: validate the Let's Encrypt challenge 114 | acme_certificate: 115 | acme_directory: "{{ letsencrypt_acme_directory }}" 116 | acme_version: "{{ letsencrypt_acme_version }}" 117 | challenge: dns-01 118 | account_key: "{{ letsencrypt_key_directory + '/' + letsencrypt_key_filename }}" 119 | csr: "{{ certificate_directory + '/' + csr_filename }}" 120 | dest: "{{ certificate_directory + '/' + crt_filename }}" 121 | account_email: "{{ letsencrypt_email }}" 122 | data: "{{ letsencrypt_challenge }}" 123 | terms_agreed: yes 124 | register: letsencrypt_validation 125 | retries: 3 126 | delay: 10 127 | until: letsencrypt_validation is success 128 | when: letsencrypt_challenge['challenge_data'] is defined 129 | tags: 130 | - web-api 131 | 132 | - name: Delete DNS Record 133 | cloudflare_dns: 134 | domain: "{{ cloudflare_domain }}" 135 | record: "_acme-challenge.{{ item.key }}" 136 | type: TXT 137 | value: "\"{{ item.value['dns-01']['resource_value'] }}\"" 138 | state: absent 139 | account_email: "{{ cloudflare_email }}" 140 | account_api_token: "{{ cloudflare_api_key }}" 141 | with_dict: "{{ letsencrypt_challenge['challenge_data'] | default({}) }}" 142 | when: letsencrypt_challenge['challenge_data'] is defined 143 | tags: 144 | - web-api 145 | 146 | - name: download the Let's Encrypt intermediate CA 147 | get_url: 148 | url: https://letsencrypt.org/certs/lets-encrypt-r3-cross-signed.pem 149 | dest: "{{ certificate_directory + '/' + intermediate_filename }}" 150 | owner: "{{ certificate_files_owner }}" 151 | group: "{{ certificate_files_group }}" 152 | mode: "{{ certificate_files_mode }}" 153 | when: include_intermediate 154 | 155 | - name: get content of the certificate 156 | command: "cat {{ certificate_directory }}/{{ crt_filename }}" 157 | register: certificate_content 158 | changed_when: false 159 | when: include_intermediate 160 | 161 | - name: get content of the intermediate CA 162 | command: "cat {{ certificate_directory }}/{{ intermediate_filename }}" 163 | register: intermediate_content 164 | changed_when: false 165 | when: include_intermediate 166 | 167 | - name: create a file with the certificate and intermediate CA concatenated 168 | copy: 169 | content: "{{ certificate_content['stdout'] + '\n' + intermediate_content['stdout'] + '\n' }}" 170 | dest: "{{ certificate_directory + '/' + fullchain_filename }}" 171 | owner: "{{ certificate_files_owner }}" 172 | group: "{{ certificate_files_group }}" 173 | mode: "{{ certificate_files_mode }}" 174 | when: include_intermediate 175 | 176 | - name: Ensure destination folders exist for certificate copy 177 | file: 178 | path: "{{ item.full_path | dirname }}" 179 | state: directory 180 | with_items: 181 | - { full_path: "{{ copy_csr_full_path }}"} 182 | - { full_path: "{{ copy_crt_full_path }}"} 183 | - { full_path: "{{ copy_key_full_path }}"} 184 | when: item.full_path != "" 185 | 186 | - name: Copy/Rename nominated files 187 | copy: 188 | src: "{{ certificate_directory + '/' + item.source_filename }}" 189 | dest: "{{ item.full_path }}" 190 | remote_src: yes 191 | with_items: 192 | - { full_path: "{{ copy_csr_full_path }}", source_filename: "{{ csr_filename }}"} 193 | - { full_path: "{{ copy_crt_full_path }}", source_filename: "{{ crt_filename }}"} 194 | - { full_path: "{{ copy_key_full_path }}", source_filename: "{{ key_filename }}"} 195 | - { full_path: "{{ copy_intermediate_full_path }}", source_filename: "{{ intermediate_filename }}"} 196 | - { full_path: "{{ copy_fullchain_full_path }}", source_filename: "{{ fullchain_filename }}"} 197 | when: item.full_path != "" 198 | 199 | - name: Remove generated files if 'cleanup_all' 200 | file: 201 | path: "{{ item.full_path }}" 202 | state: absent 203 | with_items: 204 | - { full_path: "{{ certificate_directory + '/' + csr_filename }}"} 205 | - { full_path: "{{ certificate_directory + '/' + crt_filename }}"} 206 | - { full_path: "{{ certificate_directory + '/' + key_filename }}"} 207 | - { full_path: "{{ certificate_directory + '/' + intermediate_filename }}"} 208 | - { full_path: "{{ certificate_directory + '/' + fullchain_filename }}"} 209 | when: cleanup_all 210 | 211 | - name: Remove certificate directory if 'cleanup_all' and didnt previously exist 212 | file: 213 | path: "{{ certificate_directory }}" 214 | state: absent 215 | when: (cleanup_all) and (not certificate_directory_stat.stat.exists) 216 | -------------------------------------------------------------------------------- /templates/openssl-request.conf.j2: -------------------------------------------------------------------------------- 1 | [req] 2 | req_extensions = v3_req 3 | distinguished_name = req_distinguished_name 4 | 5 | [ req_distinguished_name ] 6 | 7 | [ v3_req ] 8 | basicConstraints = CA:FALSE 9 | keyUsage = digitalSignature, keyEncipherment 10 | subjectAltName = @alt_names 11 | 12 | [alt_names] 13 | DNS = {{ certificate_common_name }} 14 | -------------------------------------------------------------------------------- /tests/inventory: -------------------------------------------------------------------------------- 1 | localhost -------------------------------------------------------------------------------- /tests/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - hosts: localhost 3 | connection: local 4 | become: yes 5 | tasks: 6 | 7 | - name: Test role with variables 8 | include_role: 9 | name: ../ansible-letsencrypt-cloudflare 10 | vars: 11 | letsencrypt_email: "a@abc.com" 12 | cloudflare_email: "a@abc.com" 13 | cloudflare_domain: "abc.com" 14 | cloudflare_api_key: "AAABBBCCCDDDEEE111222333" 15 | certificate_directory: "/tmp/cert_test" 16 | letsencrypt_key_directory: "/tmp/cert_test/letsencrypt" 17 | include_intermediate: yes --------------------------------------------------------------------------------