├── .gitignore ├── .vimrc ├── tasks ├── main.yml ├── install.yml └── generate-certs.yml ├── templates └── conf.json.j2 ├── meta └── main.yml ├── defaults └── main.yml ├── LICENSE ├── README.md └── files └── generate-certs /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | -------------------------------------------------------------------------------- /.vimrc: -------------------------------------------------------------------------------- 1 | set expandtab 2 | set tabstop=2 3 | set shiftwidth=2 4 | set softtabstop=2 5 | -------------------------------------------------------------------------------- /tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - include: "install.yml" 3 | - include: "generate-certs.yml" 4 | -------------------------------------------------------------------------------- /templates/conf.json.j2: -------------------------------------------------------------------------------- 1 | {{ { 2 | "vhosts": simp_le_vhosts, 3 | "email": simp_le_email, 4 | "dest": simp_le_dest 5 | } | to_json }} 6 | -------------------------------------------------------------------------------- /meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dependencies: [] 3 | 4 | galaxy_info: 5 | author: L-P 6 | description: simp_le installation and certificate generation for Ubuntu/Debian. 7 | company: "Boaterfly" 8 | license: "license (BSD, MIT)" 9 | min_ansible_version: 1.9 10 | platforms: 11 | - name: Debian 12 | versions: 13 | - all 14 | - name: Ubuntu 15 | versions: 16 | - trusty 17 | categories: 18 | - https 19 | - letsencrypt 20 | - ssl 21 | - tls 22 | - web 23 | -------------------------------------------------------------------------------- /defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # The two below are mandatory, see README.md 3 | # simp_le_email: 4 | # simp_le_vhosts: 5 | 6 | simp_le_repo: "https://github.com/zenhack/simp_le/" 7 | simp_le_version: "63a43b8547cd9fbc87db6bcd9497c6e37f73abef" 8 | simp_le_cache: "/home/{{ansible_user_id}}/.cache/ansible-simp_le" 9 | simp_le_dest: "{{simp_le_cache}}/simp_le" 10 | 11 | simp_le_dependencies: 12 | - "ca-certificates" 13 | - "build-essential" 14 | - "libssl-dev" 15 | - "libffi-dev" 16 | - "python" 17 | - "python-dev" 18 | - "python-virtualenv" 19 | 20 | simp_le_python_dependencies: 21 | - "setuptools" 22 | - "pip" 23 | - "wheel" 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Boaterfly 2 | 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /tasks/install.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Clone simp_le 3 | git: 4 | repo="{{simp_le_repo}}" 5 | version="{{simp_le_version}}" 6 | dest="{{simp_le_dest}}" 7 | register: git 8 | 9 | # Duplicate bootstrap.sh behavior. 10 | - name: Install dependencies. 11 | become: yes 12 | apt: 13 | name="{{item}}" 14 | cache_valid_time="86400" 15 | update_cache=yes 16 | with_items: "{{simp_le_dependencies}}" 17 | 18 | # If the git repo has changed we need to rebuild the venv 19 | - name: nuke the venv 20 | file: 21 | state=absent 22 | path="{{simp_le_dest}}/venv" 23 | when: git.changed 24 | 25 | - name: make venv dir 26 | file: 27 | state=directory 28 | path="{{simp_le_dest}}/venv" 29 | when: git.changed 30 | 31 | # Duplicate venv.sh behavior. 32 | - name: Setup venv. 33 | pip: 34 | virtualenv="{{simp_le_dest}}/venv" 35 | name="{{item}}" 36 | chdir="{{simp_le_dest}}" 37 | state=latest 38 | with_items: "{{simp_le_python_dependencies}}" 39 | 40 | - name: Setup simp_le. 41 | pip: 42 | virtualenv="{{simp_le_dest}}/venv" 43 | name="{{simp_le_dest}}" 44 | chdir="{{simp_le_dest}}" 45 | -------------------------------------------------------------------------------- /tasks/generate-certs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create root dirs. 3 | become: yes 4 | file: 5 | state="directory" 6 | path="{{item.root}}" 7 | owner="{{ansible_user_id}}" 8 | group="www-data" 9 | mode=0750 10 | with_items: "{{simp_le_vhosts}}" 11 | 12 | - name: Create output dirs. 13 | become: yes 14 | file: 15 | state="directory" 16 | path="{{item.output}}" 17 | owner="{{ item.user | default(ansible_user_id)}}" 18 | group="{{ item.group | default("www-data") }}" 19 | mode=0750 20 | with_items: "{{simp_le_vhosts}}" 21 | 22 | - name: Copy executable. 23 | copy: 24 | src="generate-certs" 25 | dest="{{simp_le_cache}}/generate-certs" 26 | mode=0775 27 | 28 | - name: Copy configuration. 29 | template: 30 | src="conf.json.j2" 31 | dest="{{simp_le_cache}}/conf.json" 32 | 33 | - name: Create certificates. 34 | command: "'{{simp_le_cache}}/generate-certs' '{{simp_le_cache}}/conf.json'" 35 | 36 | - name: Add certificate renewal cron. 37 | cron: 38 | name="renew certificates" 39 | job="'{{simp_le_cache}}/generate-certs' '{{simp_le_cache}}/conf.json'" 40 | minute="{{ 59 |random}}" hour="{{ 23 |random}}" 41 | 42 | - name: Fix ownerships. 43 | become: yes 44 | file: 45 | path="{{item.output}}" 46 | state="directory" 47 | owner="{{ item.user | default(ansible_user_id)}}" 48 | group="{{ item.group | default("www-data") }}" 49 | mode="u+rwX,g+rX,o=" 50 | recurse=yes 51 | with_items: "{{simp_le_vhosts}}" 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ansible-role-simp_le 2 | ==================== 3 | Install [simp_le](https://github.com/kuba/simp_le.git), generate certificates 4 | and renew them automatically on Debian/Ubuntu servers. 5 | 6 | Renewal will be attempted daily via a cron job run by the Ansible remote user. 7 | 8 | See the role on Ansible Galaxy: [L-P.simp_le](https://galaxy.ansible.com/L-P/simp_le/) 9 | 10 | Note: I started using [acmetool](https://github.com/L-P/ansible-role-acmetool) 11 | and recommand you do the same for any new server running Ubuntu ≥ 16.04. 12 | 13 | ## Required variables 14 | A list of virtual hosts for which we'll generate certificates: 15 | ```yaml 16 | simp_le_vhosts: 17 | - domains: ["www.example.com", "example.com"] 18 | root: "/path/to/challenges" # accessible via HTTP 19 | output: "/path/to/output/dir" # where to write the certificates 20 | ``` 21 | 22 | An email address LetsEncrypt will use to identify you and send renewal notices: 23 | ```yaml 24 | simp_le_email: "your.email@example.com" 25 | ``` 26 | 27 | There are three optional keys you can set on hosts: 28 | 29 | - `user` and `group` to specifiy who will own the keys, challenges and their parent directory 30 | The owner defaults to `www-data:www-data`. 31 | - `extra_args` to pass extra arguments to simp_le, this can be used to use the 32 | LetsEncrypt staging server or to tell simp_le to reuse the key pair when 33 | renewing the certificate. This is useful if you are using TLSA records, you 34 | can then use Selector type 1 (SubjectPublicKeyInfo) and your TLSA record will 35 | not need changing when the certificate is renewed. 36 | - `update_action` a command to be run when a certificate is renewed, 37 | e.g. `systemctl restart apache2` 38 | 39 | Example: 40 | ```yaml 41 | simp_le_vhosts: 42 | - domains: ["smtp.example.com", "mail.example.com"] 43 | root: "/path/to/challenges" 44 | output: "/path/to/output/dir" 45 | user: "Debian-exim" 46 | group: "Debian-exim" 47 | extra_args: "--reuse_key --server https://acme-staging.api.letsencrypt.org/directory" 48 | update_action: "/bin/systemctl restart exim4" 49 | ``` 50 | 51 | See `defaults/main.yml` for more configuration. 52 | 53 | ## Server configuration 54 | Your server needs to serve the challenge files over HTTP, here is an example 55 | configuration you can use for _nginx_ that will redirect every HTTP request to 56 | HTTPS except for the challenges: 57 | 58 | ```nginx 59 | location /.well-known/acme-challenge/ { 60 | alias /var/www/challenges/.well-known/acme-challenge/; 61 | try_files $uri @forward_https; 62 | } 63 | location @forward_https { 64 | return 301 https://example.com$request_uri; 65 | } 66 | location / { 67 | return 301 https://example.com$request_uri; 68 | } 69 | ``` 70 | 71 | ## Example playbook 72 | ```yaml 73 | - hosts: all 74 | roles: 75 | - {role: "L-P.simp_le", become: no} 76 | ``` 77 | 78 | While most of the operations are done without `sudo`, it is still used to 79 | create the various directories with the proper permissions and owners. 80 | -------------------------------------------------------------------------------- /files/generate-certs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # pylint: disable=invalid-name 3 | # pylint: enable=invalid-name 4 | """ 5 | Wrap simp_le CLI to make crons and automation easier 6 | """ 7 | 8 | import json 9 | import os 10 | import subprocess 11 | import sys 12 | 13 | _GENERATED_FILES = [ 14 | "account_key.json", "fullchain.pem", 15 | "key.pem", "cert.pem", "chain.pem" 16 | ] 17 | 18 | def create_certkey(output_dir): 19 | """ 20 | Create the certkey.pem file which contains both the certificate and the 21 | server private key. 22 | """ 23 | certkey_path = os.path.join(output_dir, "certkey.pem") 24 | input_files = [ 25 | os.path.join(output_dir, "cert.pem"), 26 | os.path.join(output_dir, "key.pem") 27 | ] 28 | 29 | with open(certkey_path, "w") as fout: 30 | for fin_path in input_files: 31 | with open(fin_path, "r") as fin: 32 | for line in fin: 33 | fout.write(line) 34 | 35 | # simp_le does not write the terminator on the last line 36 | if line[-1] != "\n": 37 | fout.write("\n") 38 | 39 | def generate_command(vhost, conf): 40 | """ 41 | Return a simp_le command that will generate the certs for the given vhost. 42 | """ 43 | command = [os.path.join(conf["dest"], "venv/bin/simp_le")] 44 | 45 | for domain in vhost["domains"]: 46 | command += ["-d", domain] 47 | 48 | command += ["--default_root", vhost["root"]] 49 | command += ["--email", conf["email"]] 50 | if 'extra_args' in vhost: 51 | command += vhost["extra_args"].strip().split(' ') 52 | 53 | for output_file in _GENERATED_FILES: 54 | command += ["-f", output_file] 55 | 56 | return command 57 | 58 | def get_conf(conf_path): 59 | """Return the configuration object with email, vhosts and dest.""" 60 | with open(conf_path, "r") as stream: 61 | return json.load(stream) 62 | 63 | 64 | def main(argv): 65 | """App entry point.""" 66 | conf = get_conf(argv[1]) 67 | 68 | for vhost in conf["vhosts"]: 69 | cmd = generate_command(vhost, conf) 70 | # simp_le returns 1 when a renewal is not needed 71 | # to avoid getting loads of emails when this script is run from cron 72 | # ignore returncode == 1 73 | updated = True 74 | try: 75 | output = subprocess.check_output(cmd, cwd=vhost["output"], 76 | stderr=subprocess.STDOUT) 77 | print output 78 | except subprocess.CalledProcessError, err: 79 | if err.returncode != 1: 80 | print err.output 81 | if err.returncode == 1: 82 | updated = False 83 | if err.returncode >= 2: 84 | raise RuntimeError( 85 | "Unable to generate certificates for " + vhost["domains"][0] 86 | ) 87 | updated = False 88 | create_certkey(vhost["output"]) 89 | if updated: 90 | if "update_action" in vhost: 91 | print "Certificate updated for (", ",".join(vhost["domains"]), ")" 92 | print "running ", vhost["update_action"] 93 | try: 94 | output = subprocess.check_output(vhost["update_action"].split(" "), cwd=vhost["output"], 95 | stderr=subprocess.STDOUT) 96 | print output 97 | except subprocess.CalledProcessError, err: 98 | print "Update action failed:" 99 | print err.output 100 | except OSError, err: # e.g. file not found 101 | print "Update action failed:" 102 | print err 103 | 104 | if __name__ == "__main__": 105 | main(sys.argv) 106 | --------------------------------------------------------------------------------