diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 94bedd6..0000000 --- a/Dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -FROM python:2-alpine -MAINTAINER Henri Dwyer - -VOLUME /certs -VOLUME /etc/letsencrypt -EXPOSE 80 - -RUN apk add --no-cache --virtual .build-deps linux-headers gcc musl-dev\ - && apk add --no-cache libffi-dev openssl-dev dialog\ - && pip install certbot\ - && apk del .build-deps\ - && mkdir /scripts - -ADD crontab /etc/crontabs -RUN crontab /etc/crontabs/crontab - -COPY ./scripts/ /scripts -RUN chmod +x /scripts/run_certbot.sh - -ENTRYPOINT [] -CMD ["crond", "-f"] diff --git a/LICENSE b/LICENSE index aac4688..9058edf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2016 Henri Dwyer +Copyright (c) 2017 Elliot Saba Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 88a6a9b..b99bf44 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,120 @@ -# docker-letsencrypt-cron -Create and automatically renew website SSL certificates using the letsencrypt free certificate authority, and its client *certbot*. +# NOTE: This repository is now in maintenance-only mode -This image will renew your certificates every 2 months, and place the lastest ones in the /certs folder in the container, and in the ./certs folder on the host. +There is a [spiritual successor maintained by Jonas Alfredsson](https://github.com/JonasAlfredsson/docker-nginx-certbot/) that has some nice new features and is much more actively maintained. +I highly suggest all users migrate their docker configs to use that docker image, as it is strictly superior to this one while still maintaning the same ease of use. -# Usage - -## Setup +# docker-nginx-certbot +Create and automatically renew website SSL certificates using the free [letsencrypt](https://letsencrypt.org/) certificate authority, and its client [*certbot*](https://certbot.eff.org/), built on top of the [nginx](https://www.nginx.com/) webserver. -In docker-compose.yml, change the environment variables: -- WEBROOT: set this variable to the webroot path if you want to use the webroot plugin. Leave to use the standalone webserver. -- DOMAINS: a space separated list of domains for which you want to generate certificates. -- EMAIL: where you will receive updates from letsencrypt. -- CONCAT: true or false, whether you want to concatenate the certificate's full chain with the private key (required for e.g. haproxy), or keep the two files separate (required for e.g. nginx or apache). -- SEPARATE: true or false, whether you want one certificate per domain or one certificate valid for all domains. +This repository was originally forked from `@henridwyer`, many thanks to him for the good idea. It has since been completely rewritten, and bears almost no resemblance to the original. This repository is _much_ more opinionated about the structure of your webservers/containers, however it is easier to use as long as all of your webservers follow the given pattern. -## Running +# Usage -### Using the automated image +Create a config directory for your custom configs: -```shell -docker run --name certbot -v `pwd`/certs:/certs --restart always -e "DOMAINS=domain1.com domain2.com" -e "EMAIL=webmaster@domain1.com" -e "CONCAT=true" -e "WEBROOT=" henridwyer/docker-letsencrypt-cron +```bash +$ mkdir conf.d ``` -### Building the image - -The easiest way to build the image yourself is to use the provided docker-compose file. - -```shell -docker-compose up -d +And a `*.conf` file in that directory (i.e. `nginx.conf`, but NOT just `.conf`): +```nginx +server { + listen 443 ssl; + server_name server.company.com; + ssl_certificate /etc/letsencrypt/live/server.company.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/server.company.com/privkey.pem; + + location / { + ... + } +} ``` -The first time you start it up, you may want to run the certificate generation script immediately: - -```shell -docker exec certbot ash -c "/scripts/run_certbot.sh" +Wrap this all up with a `docker-compose.yml` file: +```yml +version: '3' +services: + frontend: + restart: unless-stopped + image: staticfloat/nginx-certbot + ports: + - 80:80/tcp + - 443:443/tcp + environment: + CERTBOT_EMAIL: owner@company.com + volumes: + - ./conf.d:/etc/nginx/user.conf.d:ro + - letsencrypt:/etc/letsencrypt +volumes: + letsencrypt: ``` -At 3AM, on the 1st of every odd month, a cron job will start the script, renewing your certificates. - -# 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. +Launch that docker-compose file, and you're good to go; `certbot` will automatically request an SSL certificate for any `nginx` sites that look for SSL certificates in `/etc/letsencrypt/live`, and will automatically renew them over time. -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. +Note: using a `server` block that listens on port 80 may cause issues with renewal. This container will already handle forwarding to port 443, so they are unnecessary. -## Haproxy example +## Templating -If you use a haproxy reverse proxy, you can add the following to your configuration file in order to pass the ACME challenge. +You may wish to template your configurations, e.g. passing in a hostname so as to be able to run multiple identical copies of this container; one per website. The docker container will use [`envsubst`](https://www.gnu.org/software/gettext/manual/html_node/envsubst-Invocation.html) to template all mounted user configs with a user-provided list of environment variables. Example: -``` haproxy -frontend http - bind *:80 - acl letsencrypt_check path_beg /.well-known +```nginx +# In user.conf.d/nginx_template.conf +server { + listen 443 ssl; + server_name ${FQDN}; + ssl_certificate /etc/letsencrypt/live/${FQDN}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/${FQDN}/privkey.pem; - use_backend certbot if letsencrypt_check + ... +} +``` -backend certbot - server certbot certbot:80 maxconn 32 +```yml +version: '3' +services: + frontend: + restart: unless-stopped + image: staticfloat/nginx-certbot + ports: + - 80:80/tcp + - 443:443/tcp + environment: + CERTBOT_EMAIL: owner@company.com + # variable names are space-separated + ENVSUBST_VARS: FQDN + FQDN: server.company.com + volumes: + - ./conf.d:/etc/nginx/user.conf.d:ro + - letsencrypt:/etc/letsencrypt +volumes: + letsencrypt: ``` -## Nginx example +# Changelog -If you use nginx as a reverse proxy, you can add the following to your configuration file in order to pass the ACME challenge. +### 1.2 +- Officially putting this repository into maintenance-only mode. -``` nginx -upstream certbot_upstream{ - server certbot:80; -} +### 1.1 +- Upgraded to Python 3 installed within the environment, various quality of life improvements around initial setup and renewal. -server { - listen 80; - location '/.well-known/acme-challenge' { - default_type "text/plain"; - proxy_pass http://certbot_upstream; - } -} +### 1.0 +- Many improvements thanks to contributors from across the globe. Together, we have drastically reduced the amount of customization needed; configs can be mounted directly into a prebuilt image, and the configurations can even be templated. -``` +### 0.8 +- Ditch cron, it never liked me anway. Just use `sleep` and a `while` loop instead. -# More information +### 0.7 +- Complete rewrite, build this image on top of the `nginx` image, and run `cron`/`certbot` alongside `nginx` so that we can have nginx configs dynamically enabled as we get SSL certificates. -Find out more about letsencrypt: https://letsencrypt.org +### 0.6 +- Add `nginx_auto_enable.sh` script to `/etc/letsencrypt/` so that users can bring nginx up before SSL certs are actually available. -Certbot github: https://github.com/certbot/certbot +### 0.5 +- Change the name to `docker-certbot-cron`, update documentation, strip out even more stuff I don't care about. -# Changelog +### 0.4 +- Rip out a bunch of stuff because `@staticfloat` is a monster, and likes to do things his way ### 0.3 - Add support for webroot mode. diff --git a/crontab b/crontab deleted file mode 100644 index c8b000b..0000000 --- a/crontab +++ /dev/null @@ -1 +0,0 @@ -0 3 1 */2 * root sh /scripts/run_certbot.sh diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 947dd22..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: '2' - -services: - certbot: - build: . - container_name: certbot - volumes: - - ./certs:/certs - restart: always - environment: - - WEBROOT="" - - DOMAINS=domain1.com domain2.com - - EMAIL=webmaster@domain1.com - - CONCAT=true - - SEPARATE=true diff --git a/example/certbot_extra_domains/README.md b/example/certbot_extra_domains/README.md new file mode 100644 index 0000000..599200c --- /dev/null +++ b/example/certbot_extra_domains/README.md @@ -0,0 +1,2 @@ +1. put your letsencript primary key domain name as the filename. +2. list extra domain names each line in the file. diff --git a/example/certbot_extra_domains/example.com b/example/certbot_extra_domains/example.com new file mode 100644 index 0000000..947fe48 --- /dev/null +++ b/example/certbot_extra_domains/example.com @@ -0,0 +1,3 @@ +www.example.com +sub1.example.com +sub2.example.com diff --git a/example/conf.d/nginx.conf b/example/conf.d/nginx.conf new file mode 100644 index 0000000..8d1769e --- /dev/null +++ b/example/conf.d/nginx.conf @@ -0,0 +1,9 @@ +server { + listen 443 ssl; + server_name yourhostname.com; + ssl_certificate /etc/letsencrypt/live/yourhostname.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/yourhostname.com/privkey.pem; + + return 200 'Let\'s Encrypt certificate successfully installed!'; + add_header Content-Type text/plain; +} diff --git a/example/docker-compose.yml b/example/docker-compose.yml new file mode 100644 index 0000000..dafa52c --- /dev/null +++ b/example/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3' + +services: + proxy: + image: staticfloat/nginx-certbot + restart: always + environment: + CERTBOT_EMAIL: "your.email@example.com" + ports: + - "80:80" + - "443:443" + volumes: + - ./conf.d:/etc/nginx/user.conf.d:ro + - ./certbot_extra_domains:/etc/certbot/extra_domains:ro + diff --git a/scripts/run_certbot.sh b/scripts/run_certbot.sh deleted file mode 100755 index 4287eba..0000000 --- a/scripts/run_certbot.sh +++ /dev/null @@ -1,59 +0,0 @@ -echo "Running certbot for domains $DOMAINS" - -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 - # - CONCAT - # - args - - local d=${CERT_DOMAINS//,*/} # read first domain - echo "Getting certificate for $CERT_DOMAINS" - certbot certonly --agree-tos --renew-by-default -n \ - --text --server https://acme-v01.api.letsencrypt.org/directory \ - --email $EMAIL -d $CERT_DOMAINS $args - ec=$? - echo "certbot exit code $ec" - if [ $ec -eq 0 ] - then - if $CONCAT - then - # concat the full chain with the private key (e.g. for haproxy) - cat /etc/letsencrypt/live/$d/fullchain.pem /etc/letsencrypt/live/$d/privkey.pem > /certs/$d.pem - else - # keep full chain and private key in separate files (e.g. for nginx and apache) - cp /etc/letsencrypt/live/$d/fullchain.pem /certs/$d.pem - cp /etc/letsencrypt/live/$d/privkey.pem /certs/$d.key - fi - echo "Certificate obtained for $CERT_DOMAINS! Your new certificate - named $d - is in /certs" - else - echo "Cerbot failed for $CERT_DOMAINS. Check the logs for details." - fi -} - -args="" -if [ $WEBROOT ] -then - args=" --webroot -w $WEBROOT" -else - args=" --standalone --standalone-supported-challenges http-01" -fi - -if $DEBUG -then - args=$args" --debug" -fi - -if $SEPARATE -then - for d in $DOMAINS - do - CERT_DOMAINS=$d - get_certificate - done -else - CERT_DOMAINS=${DOMAINS// /,} - get_certificate -fi diff --git a/src/Dockerfile b/src/Dockerfile new file mode 100644 index 0000000..1e432a2 --- /dev/null +++ b/src/Dockerfile @@ -0,0 +1,32 @@ +FROM nginx +LABEL maintainer="Elliot Saba , Valder Gallo , Bruno Zell " + +VOLUME /etc/letsencrypt +EXPOSE 80 +EXPOSE 443 + +# Do this apt/pip stuff all in one RUN command to avoid creating large +# intermediate layers on non-squashable docker installs +RUN apt update && \ + apt install -y python3 python3-dev libffi6 libffi-dev libssl-dev curl build-essential procps && \ + curl -L 'https://bootstrap.pypa.io/get-pip.py' | python3 && \ + pip install -U cffi certbot && \ + apt remove --purge -y python3-dev build-essential libffi-dev libssl-dev curl && \ + apt-get autoremove -y && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Copy in scripts for certbot +COPY ./scripts/ /scripts +RUN chmod +x /scripts/*.sh + +# Add /scripts/startup directory to source more startup scripts +RUN mkdir -p /scripts/startup + +# Copy in default nginx configuration (which just forwards ACME requests to +# certbot, or redirects to HTTPS, but has no HTTPS configurations by default). +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/src/Makefile b/src/Makefile new file mode 100644 index 0000000..879ff77 --- /dev/null +++ b/src/Makefile @@ -0,0 +1,22 @@ + +$(warning $(shell IMAGE_NAME=$(IMAGE_NAME) printenv | grep IMAGE_NAME)) +ifndef IMAGE_NAME + #$(warning IMAGE_NAME is not set) + IMAGE_NAME=nginx-certbot +endif + +# If we have `--squash` support, then use it! +ifneq ($(shell docker build --help 2>/dev/null | grep squash),) +DOCKER_BUILD = docker build --squash +else +DOCKER_BUILD = docker build +endif + +all: build + +build: Makefile Dockerfile + $(DOCKER_BUILD) -t $(IMAGE_NAME) . + @echo "Done! Use docker run $(IMAGE_NAME) to run" + +push: + docker push $(IMAGE_NAME) diff --git a/src/nginx_conf.d/certbot.conf b/src/nginx_conf.d/certbot.conf new file mode 100644 index 0000000..a045362 --- /dev/null +++ b/src/nginx_conf.d/certbot.conf @@ -0,0 +1,16 @@ +server { + # Listen on plain old HTTP + listen 80 default_server reuseport; + listen [::]:80 default_server reuseport; + + # Pass this particular URL off to certbot, to authenticate HTTPS certificates + location '/.well-known/acme-challenge' { + default_type "text/plain"; + proxy_pass http://localhost:1337; + } + + # Everything else gets shunted over to HTTPS + location / { + return 301 https://$http_host$request_uri; + } +} diff --git a/src/scripts/entrypoint.sh b/src/scripts/entrypoint.sh new file mode 100644 index 0000000..f2101fd --- /dev/null +++ b/src/scripts/entrypoint.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# When we get killed, kill all our children +trap "exit" INT TERM +trap "kill 0" EXIT + +# Source in util.sh so we can have our nice tools +. $(cd $(dirname $0); pwd)/util.sh + +# first include any user configs if they've been mounted +template_user_configs + +# 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;" & +NGINX_PID=$! + +# Lastly, run startup scripts +for f in /scripts/startup/*.sh; do + if [ -x "$f" ]; then + echo "Running startup script $f" + $f + fi +done +echo "Done with startup" + +# Instead of trying to run `cron` or something like that, just sleep and run `certbot`. +while [ true ]; do + # Make sure we do not run container empty (without nginx process). + # If nginx quit for whatever reason then stop the container. + # Leave the restart decision to the container orchestration. + if ! ps aux | grep --quiet [n]ginx ; then + exit 1 + fi + + # Run certbot, tell nginx to reload its config + echo "Run certbot" + /scripts/run_certbot.sh + kill -HUP $NGINX_PID + + # Sleep for 1 week + sleep 604810 & + SLEEP_PID=$! + + # Wait for 1 week sleep or nginx + wait -n "$SLEEP_PID" "$NGINX_PID" +done diff --git a/src/scripts/run_certbot.sh b/src/scripts/run_certbot.sh new file mode 100644 index 0000000..e890eff --- /dev/null +++ b/src/scripts/run_certbot.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# 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 + +exit_code=0 +set -x +# Loop over every domain we can find +for domain in $(parse_domains); do + if is_renewal_required $domain; then + extra_domains=$(parse_extra_domains $domain) + renewal_domains="$domain $extra_domains" + # Renewal required for this doman. + # Last one happened over a week ago (or never) + if ! get_certificate "$renewal_domains" $CERTBOT_EMAIL; then + error "Cerbot failed for $renewal_domain. Check the logs for details." + exit_code=1 + fi + else + echo "Not run certbot for $domain; last renewal happened just recently." + fi +done + +# After trying to get all our certificates, auto enable any configs that we +# did indeed get certificates for +auto_enable_configs + +set +x +exit $exit_code diff --git a/src/scripts/util.sh b/src/scripts/util.sh new file mode 100644 index 0000000..3d8391f --- /dev/null +++ b/src/scripts/util.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +# 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 +} + +parse_extra_domains() { + extra_domains_conf=/etc/certbot/extra_domains/$1 + if [ -f $extra_domains_conf ]; then + cat $extra_domains_conf | xargs echo + fi +} + +# 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() { + sed -n -r -e 's&^\s*ssl_certificate_key\s*\/etc/letsencrypt/live/(.*)/privkey.pem;\s*(#.*)?$&\1&p' /etc/nginx/conf.d/*.conf* | sort | uniq | xargs echo +} + +# 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 + currentfile=${keyfile//$'\r'/} + if [ ! -f $currentfile ]; then + echo "Couldn't find keyfile $currentfile 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" + PRODUCTION_URL='https://acme-v02.api.letsencrypt.org/directory' + STAGING_URL='https://acme-staging-v02.api.letsencrypt.org/directory' + + if [ "${IS_STAGING}" = "1" ]; then + letsencrypt_url=$STAGING_URL + echo "Staging ..." + else + letsencrypt_url=$PRODUCTION_URL + echo "Production ..." + fi + + opt_domains=$(for i in $1; do printf -- "-d $i "; done;) + + echo "running certbot ... $letsencrypt_url $1 $2" + certbot certonly --expand --agree-tos --keep -n --text --email $2 --server \ + $letsencrypt_url $opt_domains --http-01-port 1337 \ + --standalone --preferred-challenges http-01 --debug +} + +# Given a domain name, return true if a renewal is required (last renewal +# ran over a week ago or never happened yet), otherwise return false. +is_renewal_required() { + # If the file does not exist assume a renewal is required + last_renewal_file="/etc/letsencrypt/live/$1/privkey.pem" + [ ! -e "$last_renewal_file" ] && return; + + # If the file exists, check if the last renewal was more than a week ago + one_week_sec=604800 + now_sec=$(date -d now +%s) + last_renewal_sec=$(stat -c %Y "$last_renewal_file") + last_renewal_delta_sec=$(( ($now_sec - $last_renewal_sec) )) + is_finshed_week_sec=$(( ($one_week_sec - $last_renewal_delta_sec) )) + [ $is_finshed_week_sec -lt 0 ] +} + +# copies any *.conf files in /etc/nginx/user.conf.d +# to /etc/nginx/conf.d so they are included as configs +# this allows a user to easily mount their own configs +# We make use of `envsubst` to allow for on-the-fly templating +# of the user configs. +template_user_configs() { + SOURCE_DIR="${1-/etc/nginx/user.conf.d}" + TARGET_DIR="${2-/etc/nginx/conf.d}" + + # envsubst needs dollar signs in front of all variable names + DENV=$(echo ${ENVSUBST_VARS} | sed -E 's/\$*([^ ]+)/\$\1/g') + + echo "templating scripts from ${SOURCE_DIR} to ${TARGET_DIR}" + echo "Substituting variables ${DENV}" + + if [ ! -d "$SOURCE_DIR" ]; then + echo "no ${SOURCE_DIR}, nothing to do." + else + for conf in ${SOURCE_DIR}/*.conf; do + echo " -> ${conf}" + envsubst "${DENV}" <"${conf}" > "${TARGET_DIR}/$(basename ${conf})" + done + fi +}