From 9f4becca7e75255f94899cfcd98d4ddaf5b085b2 Mon Sep 17 00:00:00 2001 From: Elliot Saba Date: Sun, 22 Jan 2017 22:33:45 -0800 Subject: [PATCH] Big rewrite so that we can enable sites as we get HTTPS certs --- Dockerfile | 14 ++++++--- Makefile | 1 + README.md | 62 +++++-------------------------------- nginx_conf.d/certbot.conf | 15 +++++++++ scripts/entrypoint.sh | 21 +++++++++++-- scripts/run_certbot.sh | 58 ++++++++++++---------------------- scripts/util.sh | 65 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 137 insertions(+), 99 deletions(-) create mode 100644 nginx_conf.d/certbot.conf create mode 100644 scripts/util.sh diff --git a/Dockerfile b/Dockerfile index ba66392..1ac796c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,22 @@ -FROM python:2 +FROM nginx MAINTAINER Elliot Saba VOLUME /etc/letsencrypt EXPOSE 80 +EXPOSE 443 -RUN apt update && apt install -y cron -RUN pip install certbot -RUN mkdir /scripts +RUN apt update && apt install -y cron python python-dev python-pip libffi-dev libssl-dev +RUN pip install -U cffi certbot +# Copy in cron job and scripts for certbot COPY ./crontab /etc/cron.d/certbot RUN crontab /etc/cron.d/certbot - COPY ./scripts/ /scripts RUN chmod +x /scripts/*.sh +# Copy in default nginx configuration +RUN rm -f /etc/nginx/conf.d/* +COPY nginx_conf.d/ /etc/nginx/conf.d/ + ENTRYPOINT [] CMD ["/bin/bash", "/scripts/entrypoint.sh"] diff --git a/Makefile b/Makefile index 090cfe3..0f27b73 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ all: build build: Makefile Dockerfile docker build --squash -t staticfloat/docker-certbot-cron . + echo "Done! Use docker run staticfloat/docker-certbot-cron to run" push: docker push staticfloat/docker-certbot-cron diff --git a/README.md b/README.md index 8dbb2ef..5e084b5 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,5 @@ # docker-certbot-cron -Create and automatically renew website SSL certificates using the letsencrypt free certificate authority, and its client *certbot*. Define the environment variables `DOMAINS` (space-separated list of fully-qualified domain names) and `EMAIL` (your letsencrypt registration email) to automatically run `certbot` to renew/fetch your SSL certificates in the background. Configure `nginx` to pass off the ACME validation challenge, and you'll have zero-downtime, 100% automatic SSL certificates for all your Docker containers! - -# ACME Validation challenge - -To authenticate the certificates, the you need to pass the ACME validation challenge. This requires requests made on port 80 to your.domain.com/.well-known/ to be forwarded to this container. - -The recommended way to use this image is to set up your reverse proxy to automatically forward requests for the ACME validation challenges to this container. - -## Nginx example - -If you use nginx as a reverse proxy, you can add the following to your configuration file in order to pass the ACME challenge. - -``` nginx -server { - listen 80; - location '/.well-known/acme-challenge' { - default_type "text/plain"; - # Note: this works with docker-compose only if the service name is `certbot`, - # and the `nginx` service `depends_on` the `certbot` service! - proxy_pass http://certbot:80; - } -} -``` - -## `docker-compose` example - -To use this container with `docker-compose`, put something like the following into your configuration: -```yml -version '2' -services: -... - certbot: - image: staticfloat/docker-certbot-cron - container_name: certbot - volumes: - - certbot_etc_letsencrypt:/etc/letsencrypt - restart: unless-stopped - environment: - - DOMAINS="foo.bar.com baz.bar.com" - - EMAIL=email@domain.com -... - nginx: - ... - depends_on: - - certbot - volumes: - - certbot_etc_letsencrypt:/etc/letsencrypt:ro -... -volumes: - certbot_etc_letsencrypt: - external: true -``` -I personally like having my certificates stored in an external volume so that if I ever accidentally run `docker-compose down` I don't have to re-issue myself the certificates. +Create and automatically renew website SSL certificates using the letsencrypt free certificate authority, and its client *certbot*. # More information @@ -59,10 +7,16 @@ Find out more about letsencrypt: https://letsencrypt.org Certbot github: https://github.com/certbot/certbot -This repository was originally forked from `@henridwyer`, many thanks to him for the good idea. I've basically taken his approach and made it less flexible/simpler for my own use cases, so if you want this repository to do something a particular way, make sure [his repo](https://github.com/henridwyer/docker-letsencrypt-cron) doesn't already do it. +This repository was originally forked from `@henridwyer`, many thanks to him for the good idea. I've rewritten about 90% of this repository, so it bears almost no resemblance to the original. This repository is _much_ more opinionated about the structure of your webservers/code, however it is easier to use as long as all of your webservers follow that pattern. # Changelog +### 0.7 +- Complete rewrite, build this image on top of the `nginx` image, and run `cron` alongside `nginx` so that we can have nginx configs dynamically enabled as we get SSL certificates. + +### 0.6 +- Add `nginx_auto_enable.sh` script to `/etc/letsencrypt/` so that users can bring nginx up before SSL certs are actually available. + ### 0.5 - Change the name to `docker-certbot-cron`, update documentation, strip out even more stuff I don't care about. diff --git a/nginx_conf.d/certbot.conf b/nginx_conf.d/certbot.conf new file mode 100644 index 0000000..2d6c5f1 --- /dev/null +++ b/nginx_conf.d/certbot.conf @@ -0,0 +1,15 @@ +server { + # Listen on plain old HTTP + listen 80; + + # Pass this particular URL off to certbot, to authenticate HTTPS certificates + location '/.well-known/acme-challenge' { + default_type "text/plain"; + proxy_pass http://localhost:80; + } + + # Everything else gets shunted over to HTTPS + location / { + return 301 https://$http_host$request_uri; + } +} diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index be8dbb4..49d879b 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,6 +1,23 @@ -#!/bin/bash +#!/bin/sh +# When we get killed, kill all our children trap "exit" INT TERM trap "kill 0" EXIT -/scripts/run_certbot.sh && cron -f & + +# Source in util.sh so we can have our nice tools +. $(cd $(dirname $0); pwd)/util.sh + +# Immediately run auto_enable_configs so that nginx is in a runnable state +auto_enable_configs + +# Start up nginx, save PID so we can reload config inside of run_certbot.sh +nginx -g "daemon off;" & +export NGINX_PID=$! + +# Next, run certbot to request all the ssl certs we can find +/scripts/run_certbot.sh + +# Run `cron -f &` so that it's a background job owned by bash and then `wait`. +# This allows SIGINT (e.g. CTRL-C) to kill cron gracefully, due to our `trap`. +cron -f & wait diff --git a/scripts/run_certbot.sh b/scripts/run_certbot.sh index 27d497b..3a68623 100755 --- a/scripts/run_certbot.sh +++ b/scripts/run_certbot.sh @@ -1,48 +1,30 @@ -error() { - (set +x; tput -Tscreen bold - tput -Tscreen setaf 1 - echo $* - tput -Tscreen sgr0) >&2 -} +#!/bin/sh -if [ -z "$DOMAINS" ]; then - error "DOMAINS environment variable undefined; certbot will do nothing" +# Source in util.sh so we can have our nice tools +. $(cd $(dirname $0); pwd)/util.sh + +# We require an email to register the ssl certificate for +if [ -z "$CERTBOT_EMAIL" ]; then + error "CERTBOT_EMAIL environment variable undefined; certbot will do nothing" exit 1 fi -if [ -z "$EMAIL" ]; then - error "EMAIL environment variable undefined; certbot will do nothing" - exit 1 -fi -echo "Running certbot for domains $DOMAINS for user $EMAIL..." - -get_certificate() { - # Gets the certificate for the domain(s) CERT_DOMAINS (a comma separated list) - # The certificate will be named after the first domain in the list - # To work, the following variables must be set: - # - CERT_DOMAINS : comma separated list of domains - # - EMAIL - - local d=${CERT_DOMAINS//,*/} # read first domain - echo "Getting certificate for $CERT_DOMAINS" - certbot certonly --agree-tos --keep -n --text --email $EMAIL --server \ - https://acme-v01.api.letsencrypt.org/directory -d $CERT_DOMAINS \ - --standalone --standalone-supported-challenges http-01 --debug - ec=$? - echo "certbot exit code $ec" - if [ $ec -eq 0 ]; then - error "Certificates for $CERT_DOMAINS can be found in /etc/letsencrypt/live/$d" - else - error "Cerbot failed for $CERT_DOMAINS. Check the logs for details." - exit 1 - fi -} exit_code=0 set -x -for d in $DOMAINS; do - CERT_DOMAINS=$d - if ! get_certificate; then +# Loop over every domain we can find +for domain in $(parse_domains); do + if ! get_certificate $domain $CERTBOT_EMAIL; then + error "Cerbot failed for $domain. Check the logs for details." exit_code=1 fi done + +# After trying to get all our certificates, auto enable any configs that we +# did indeed get certificates for +auto_enable_configs + +# Finally, tell nginx to reload the configs +kill -HUP $NGINX_PID + +set +x exit $exit_code diff --git a/scripts/util.sh b/scripts/util.sh new file mode 100644 index 0000000..ec045e1 --- /dev/null +++ b/scripts/util.sh @@ -0,0 +1,65 @@ +#!/bin/sh + +# Helper function to output error messages to STDERR, with red text +error() { + (set +x; tput -Tscreen bold + tput -Tscreen setaf 1 + echo $* + tput -Tscreen sgr0) >&2 +} + +# Helper function that sifts through /etc/nginx/conf.d/, looking for lines that +# contain ssl_certificate_key, and try to find domain names in them. We accept +# a very restricted set of keys: Each key must map to a set of concrete domains +# (no wildcards) and each keyfile will be stored at the default location of +# /etc/letsencrypt/live//privkey.pem +parse_domains() { + # For each configuration file in /etc/nginx/conf.d/*.conf* + for conf_file in /etc/nginx/conf.d/*.conf*; do + sed -n -e 's/^\s*ssl_certificate_key\s*\/etc/letsencrypt/live/(.*\)/privkey.pem;/\1/p' $conf_file | tr '\n' ',' + done +} + +# Given a config file path, spit out all the ssl_certificate_key file paths +parse_keyfiles() { + sed -n -e 's/^\s*ssl_certificate_key\s*\(.*\);/\1/p' "$1" +} + +# Given a config file path, return 0 if all keyfiles exist (or there are no +# keyfiles), return 1 otherwise +keyfiles_exist() { + for keyfile in $(parse_keyfiles $1); do + if [ ! -f $keyfile ]; then + echo "Couldn't find keyfile $keyfile for $1" + return 1 + fi + done + return 0 +} + +# Helper function that sifts through /etc/nginx/conf.d/, looking for configs +# that don't have their keyfiles yet, and disabling them through renaming +auto_enable_configs() { + for conf_file in /etc/nginx/conf.d/*.conf*; do + if ! keyfiles_exist $conf_file; then + if [ $conf_file == *.nokey ]; then + echo "Found all the keyfiles for $conf_file, enabling..." + mv $conf_file ${conf_file%.*} + fi + else + if [ $conf_file == *.conf ]; then + echo "Keyfile(s) missing for $conf_file, disabling..." + mv $conf_file $conf_file.nokey + fi + fi + done +} + +# Helper function to ask certbot for the given domain(s). Must have defined the +# EMAIL environment variable, to register the proper support email address. +get_certificate() { + echo "Getting certificate for domain $1 on behalf of user $2" + return certbot certonly --agree-tos --keep -n --text --email $2 --server \ + https://acme-v01.api.letsencrypt.org/directory -d $1 \ + --standalone --standalone-supported-challenges http-01 --debug +}