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