From 63f18972f9f945c3a8d6327bef182f2dae437bed Mon Sep 17 00:00:00 2001 From: Thor Harald Johansen Date: Thu, 21 May 2020 19:33:52 +0200 Subject: [PATCH] Initial commit --- .gitignore | 2 + LICENSE | 20 +++++++ README.md | 104 ++++++++++++++++++++++++++++++++++++ certbot.py | 109 ++++++++++++++++++++++++++++++++++++++ certbot_config.example.py | 30 +++++++++++ 5 files changed, 265 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100755 certbot.py create mode 100644 certbot_config.example.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90ea3d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +certbot_config.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8a0b9e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (C) 2020 T.H.J. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..951c463 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Docker Nginx Certbot script + +This is a Python 3 script that configures Let's Encrypt for an Nginx +server running inside of a Docker container. + +## certbot.py + +The main script. It expects a config file, located in the same directory, +named `certbot_config.py`. Make a copy of the included example configuration +and edit to taste. + +## Dependencies + +``` +pip3 install crossplane +``` + +## Setup + +`certbot` expects a file in the `.well-known` directory on the webroot when +verifying domains. Certbot typically modifies the Nginx configuration files +and reloads them underway in order to serve this directory via plain HTTP +before SSL is configured. Since the script uses the `webroot` plugin, we +need to take a different approach: + +The script assumes that the SSL configuration directives for each Nginx host +is kept in separate files under `/etc/nginx/conf.d` per host. Before SSH is +enabled for a new host, its HTTPS configuration file should be a dotfile +in order to prevent Nginx from attempting to load it. + +The plain HTTP configuration file for a host should look roughly like this: + +``` +server { + listen 80; + server_name host.tld www.host.tld; + + location ~ /.well-known { + root /usr/share/nginx/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} +``` + +It resembles the HTTP half of an Nginx host configuration file generated by +Certbot's Nginx plugin; the difference being that it serves `.well-known` +if requested, instead of unconditionally redirecting to HTTPS. The path +`/usr/share/nginx/certbot` on the container, mounted somewhere on the host, +is used by Certbot to configure all domains for SSL. + +For `certbot.py` to work, it's required that all `include` statements in +your Nginx configuration files use relative paths, or Crossplane won't be +able to parse them properly. + +For convenience, create a file `/etc/nginx/ssl.conf` looking roughly like this: + +``` +ssl_certificate /etc/letsencrypt/live/$PRIMARY_DOMAIN/fullchain.pem; +ssl_certificate_key /etc/letsencrypt/live/$PRIMARY_DOMAIN/privkey.pem; + +ssl_dhparam ssl-dhparams.pem; + +ssl_session_cache shared:le_nginx_SSL:1m; +ssl_session_timeout 1440m; + +ssl_protocols TLSv1 TLSv1.1 TLSv1.2; +ssl_prefer_server_ciphers on; + +ssl_ciphers "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA:!DSS"; +``` + +Be sure to substitute `$PRIMARY_DOMAIN` for the primary domain in your +`certbot_config.py` file, as this is the location Certbot will place your +certificates in. + +The SSL version of your Nginx host configuration file would look roughly like this: + +``` +server { + listen 443 ssl; + server_name host.tld www.host.tld; + + include ssl.conf; + + location / { + root /usr/share/nginx/thj.no; + index index.html index.htm; + } +} +``` + +With these files in place, you can reload Nginx and run `./certbot.py`. +Certificate configuration should then commence in the usual fashion. + +Once completed, rename all your SSL dotfiles to regular dotfiles and +reload Nginx again. You should now have a working SSL configuration +for all your domains. + +To add new domains, simply follow the same procedure as above, while +leaving the other configuration files in place, and `certbot` will +generate a new certificate containing the new domains. diff --git a/certbot.py b/certbot.py new file mode 100755 index 0000000..f78b3b7 --- /dev/null +++ b/certbot.py @@ -0,0 +1,109 @@ +#!/usr/bin/python3 + +# Dependencies: +# pip3 install crossplane +# https://github.com/nginxinc/crossplane + +import sys +import crossplane +import pprint +import shlex +from subprocess import run + +import certbot_config as config + +# Resolve include directive (non-recursive) +def resolve_include(response, directive): + result = [] + for index in directive['includes']: + result.extend(response['config'][index]['parsed']) + return result + +# Resolve all include directives (recursive) +def resolve_includes(response, directives=None): + # Assume resolve of first file in response if not specified + if directives is None: + directives = response['config'][0]['parsed'] + + # Rebuild directive tree with includes in-place + result = [] + for directive in directives: + if 'includes' in directive: + result.extend(resolve_includes(response, resolve_include(response, directive))) + continue + + if 'block' in directive: + directive['block'] = resolve_includes(response, directive['block']) + + result.append(directive) + + return result + +# Search in directives using matching function +def search_directives(directives, fn, recursive=False): + result = [] + + for directive in directives: + if fn(directive): + result.append(directive) + elif recursive and 'block' in directive: + result.extend(search_directives(directive['block'], fn, True)) + + return result + +# Recursive search in directives, steered by matching functions +def resolve_path(directives, fns): + results = search_directives(directives, fns[0]) + if len(results) > 0 and len(fns) > 1: + return resolve_path(results[0]['block'], fns[1:]) + else: + return results + +# Get all HTTP server directives +def get_servers(directives): + return resolve_path(directives, [ + lambda d: ('directive', 'http') in d.items(), + lambda d: ('directive', 'server') in d.items()]) + +# Generate { server_name: root } dictionary of virtual hosts +def get_vhosts(directives): + servers = get_servers(directives) + vhosts = [] + for server in servers: + server_name = search_directives(server['block'], lambda d: ('directive', 'server_name') in d.items()) + if len(server_name) == 0: + continue + + for name in server_name[0]['args']: + vhosts.append(name) + + return vhosts + +response = crossplane.parse(config.nginx_config_file) +if response['errors']: + pprint.pprint(response['errors']) + +directives = resolve_includes(response) + +vhosts = [ name for name in get_vhosts(directives) if not name in config.ignored_domains ] + +if config.primary_domain in vhosts: + vhosts.remove(config.primary_domain) + vhosts.insert(0, config.primary_domain) +else: + print('Primary domain is missing from configuration') + sys.exit(1) + +print() +print('Found virtual hosts:') +for name in vhosts: + print(' {}'.format(name)) +print() + +cmd = config.certbot_command + +for name in vhosts: + cmd.extend(['-d', name]) +print('Running', ' '.join(cmd)) +print() +run(cmd) diff --git a/certbot_config.example.py b/certbot_config.example.py new file mode 100644 index 0000000..e574389 --- /dev/null +++ b/certbot_config.example.py @@ -0,0 +1,30 @@ +import sys +import os + +# Docker project directory +project_dir = os.path.dirname(sys.argv[0]) + +# Email address for Let's Encrypt notices +email = 'user@host.tld' + +# First domain in Certbot command line +primary_domain = 'domain.tld' + +# Domains to ignore +ignored_domains = ['localhost'] + +# Location of Nginx configuration file +nginx_config_file = os.path.join(project_dir, 'data/etc/nginx/nginx.conf') + +# Default arguments for Certbot +certbot_command = ['certbot', + 'certonly', + '--config-dir', os.path.join(project_dir, 'data/etc/letsencrypt'), +# '--post-hook', 'docker-compose -f {} restart'.format(shlex.quote(os.path.join(PROJECT_DIR, 'docker-compose.yml'))), + '--webroot', +# '--staging', + '--agree-tos', + '--expand', + '-n', + '-m', email, + '-w', os.path.join(project_dir, 'data/usr/share/nginx/certbot')]