commit
63f18972f9
5 changed files with 265 additions and 0 deletions
@ -0,0 +1,2 @@ |
||||
__pycache__ |
||||
certbot_config.py |
@ -0,0 +1,20 @@ |
||||
Copyright (C) 2020 T.H.J. <thj@thj.no> |
||||
|
||||
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. |
@ -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. |
@ -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) |
@ -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')] |
Loading…
Reference in new issue