Skip to content

Commit

Permalink
Merge pull request #1 from nbraun1/multi-certificates
Browse files Browse the repository at this point in the history
Multi certificates
  • Loading branch information
nbraun1 authored May 20, 2022
2 parents bb6f719 + 73dfa52 commit cfb935a
Show file tree
Hide file tree
Showing 9 changed files with 198 additions and 39 deletions.
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,6 @@ ENV KEY_TYPE=
ENV ELLIPTIC_CURVE=

# path options
ENV CONFIG_DIR=
ENV WORK_DIR=
ENV LOGS_DIR=
ENV SERVER=

# renew options
Expand All @@ -55,12 +52,15 @@ ENV CERTBOT_RENEW_FLAGS=
ENV DNS_PLUGINS=
ENV RUN_ONCE=
ENV CRON="0 0,12 * * *"
ENV ENABLE_MULTI_CERTIFICATES=
ENV MULTI_CERTIFICATES_INI_FILE=/etc/certbot/multi-certificates.ini

RUN set -ex; \
apk add --no-cache \
bash \
tini \
docker-cli \
python3 \
py3-pip \
certbot

Expand Down
47 changes: 37 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ Open Source and free to use [certbot][1] for Docker environments to automate the
- Install [certbot's DNS plugins](https://eff-certbot.readthedocs.io/en/stable/using.html#dns-plugins) with [pip][3] when starting the Docker container
- Efficient signal handling with [Tini](https://github.com/krallin/tini)
- Highly configurable with [environment variables](#environment-variables)
- Capable to obtain and automatically renew [multiple certificates](#multiple-certificates) (since version 1.1.0)

# Table of Contents
- [Getting Started](#getting-started)
- [Run with docker run](#run-with-docker-run)
- [Run with docker compose](#run-with-docker-compose)
- [Multiple Certificates](#multiple-certificates)
- [Basic Setup](#basic-setup)
- [INI File](#ini-file)
- [Technical Background Knowledge](#technical-background-knowledge)
- [Environment Variables](#environment-variables)
- [Required](#required)
- [Optional](#optional)
Expand Down Expand Up @@ -62,10 +67,34 @@ docker run -it -p 80:81 -v $(pwd)/data/certbot:/etc/letsencrypt \
```
[Certbot][1] listens to port 81 in the Docker container but is mapped as port 80 to the host in order to be reachable for a ACME server.

Run [certbot][1] for multiple certificates:
```bash
docker run -it -p 80:80 \
-v $(pwd)/data/certbot:/etc/letsencrypt \
-v $(pwd)/example.ini:/etc/certbot/multi-certificates.ini \
-e ENABLE_MULTI_CERTIFICATES=1 \
--name certbot nbraun1/certbot
```
For detailed information how the multi-certificates feature works, read the [multiple certificates](#multiple-certificates) section.

## Run with `docker compose`
For an example to run [certbot][1] in Docker Compose consult our [docker-compose.yml](./examples/docker-compose.yml). In order to start the [certbot][1] run `docker compose up` in your command line. More examples can be found in the [examples directory](./examples/).
> Note that we use [Docker Compose V2](https://docs.docker.com/compose/#compose-v2-and-the-new-docker-compose-command) for this example.
# Multiple Certificates
Are you tired of running multiple `docker run` commands for the same [certbot][1] Docker image to obtain or renew multiple certificates? Or repeat your [certbot][1] service in your docker-compose.yml where each of them manage a separate domain? Or write the ugly configuration for one [certbot][1] service to force a semi multi-certificates feature? Our [Docker image](https://hub.docker.com/r/nbraun1/certbot) provides a much simpler and more pleasant way!

## Basic Setup
Our multi-certificates feature is based on an INI file which is written by you. For an simple example have a look at our pre-defined [example.ini](./examples/multi-certificates/example.ini) file. This whole feature is optional, means that you can decide with the `ENABLE_MULTI_CERTIFICATES` environment variable if you enable or disable it. In the [run with docker run](#run-with-docker-run) section you safely noticed that an additional volume is used when running with an defined `ENABLE_MULTI_CERTIFICATES` environment variable. This volume only contains the INI file and is located at `/etc/certbot/multi-certificates.ini` in the Docker container by default. That location can be changed with the `MULTI_CERTIFICATES_INI_FILE` environment variable.

## INI File
The INI file contains one optional *DEFAULT* section and one or more domain specific sections. Each option defined in the *DEFAULT* section is applied to the domain specific section options. If a *DEFAULT* option is the same as the domain specific one, the domain specific one overrides the *DEFAULT* one and is used. Possible options and its values are the environment variables defined in the corresponding [section](#environment-variables).

## Technical Background Knowledge
Reading this section is not mandatory to understand the multi-certificates feature but might be helpfully if you are interested in general technical background knowledge.

To parse the INI file we use Python and **not** Bash! You might be wondering: "Why using Python to parse a file in a Docker container which uses Bash by default?!". The answer is really simple. There are a handful existing INI file parsers available in GitHub but most of these are either a (dirty) hack, incomplete, do not work or do not meet our requirements. The alternatives are e.g *awk* or *sed* scripts but we think this kind of solution is not maintainable, not really smart and above all error prone. So we decided to use Python and its [config parser](https://docs.python.org/3/library/configparser.html) module to parse the INI file.

# Environment Variables
This section is partially based on the official [certbot command line options](https://eff-certbot.readthedocs.io/en/stable/using.html#certbot-command-line-options) documentation. Most of the environment variables defaults to an empty string which is in most cases equivalent to a boolean `false`. If you wish to set this environment variable to a boolean `true`, leave its value to `1` or any other non-empty string. There are also some environment variables wish require a string or number but each of them have a well documentation to describe its expectation.

Expand Down Expand Up @@ -141,15 +170,6 @@ This section is partially based on the official [certbot command line options](h
`ELLIPTIC_CURVE`
> The SECG [elliptic curve](https://datatracker.ietf.org/doc/html/rfc8446#section-7.4.2) name to use. Default is secp256r1.
---
`CONFIG_DIR`
> Configuration directory. Default is /etc/letsencrypt.
---
`WORK_DIR`
> Working directory. Default is /var/lib/letsencrypt.
---
`LOGS_DIR`
> Logs directory. Default is /var/log/letsencrypt.
---
`SERVER`
> ACME Directory Resource URI. Default is `https://acme-v02.api.letsencrypt.org/directory`.
---
Expand All @@ -160,7 +180,7 @@ This section is partially based on the official [certbot command line options](h
> Command to be run in a shell after attempting to obtain/renew certificates. Can be used to deploy renewed certificates or to restart any servers that were stopped by `PRE_HOOK_CMD`. This is only run if an attempt was made to obtain/renew a certificate. If multiple renewed certificates have identical post-hooks, only one will be run.
---
`DEPLOY_HOOK_CMD`
> Command to be run in a shell once for each successfully issued certificate. For this command, the shell variable *$RENEWED_LINEAGE* will point to the `CONFIG_DIR` live subdirectory (for example, "/etc/letsencrypt/live/example.com") containing the new certificates and keys; the shell variable *$RENEWED_DOMAINS* will contain a space separated list of renewed certificate domains (for example, "`example.com www.example.com`").
> Command to be run in a shell once for each successfully issued certificate. For this command, the shell variable *$RENEWED_LINEAGE* will point to the `/etc/letsencrypt` live subdirectory (for example, "/etc/letsencrypt/live/example.com") containing the new certificates and keys; the shell variable *$RENEWED_DOMAINS* will contain a space separated list of renewed certificate domains (for example, "`example.com www.example.com`").
---
`CERTBOT_CERTONLY_FLAGS`
> Additional command line options for [certbot's][1] certonly command.
Expand All @@ -176,9 +196,16 @@ This section is partially based on the official [certbot command line options](h
---
`CRON`
> [Cron](https://crontab.guru/crontab.5.html) expression for [certbot's][1] automatically renewal. If you have no idea of how to write such an cron expression, use [crontab guru](https://crontab.guru/) to generate one.
---
`ENABLE_MULTI_CERTIFICATES`
> If defined, the [multi-certificates](#multiple-certificates) feature is enabled. Disabled by default.
---
`MULTI_CERTIFICATES_INI_FILE`
> Change the default INI file location from `/etc/certbot/multi-certificates.ini` to another one. Ignored if `ENABLE_MULTI_CERTIFICATES` is undefined.
# Volumes
- `/etc/letsencrypt` - stores the obtained certificates.
- `/etc/certbot/multi-certificates.ini` - the INI file for the [multi-certificates](#multiple-certificates) feature. Must be mounted manually and is optional, i.e is not exposed by the Dockerfile.

# Exposed Ports
- 80
Expand Down
11 changes: 11 additions & 0 deletions examples/multi-certificates/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
version: "3.9"
services:
certbot:
image: nbraun1/certbot
environment:
ENABLE_MULTI_CERTIFICATES: 1
ports:
- 80:80
volumes:
- ./data/certbot:/etc/letsencrypt
- ./example.ini:/etc/certbot/multi-certificates.ini
9 changes: 9 additions & 0 deletions examples/multi-certificates/example.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[DEFAULT]
EMAIL[email protected]
CRON="* * * * *"

[example]
DOMAINS=example.com,www.example.com

[example2]
DOMAINS=example2.com,www.example2.com
12 changes: 0 additions & 12 deletions scripts/certbot-certonly.sh
Original file line number Diff line number Diff line change
Expand Up @@ -125,18 +125,6 @@ if [ ! -z "$ELLIPTIC_CURVE" ]; then
fi

# add path options
if [ ! -z "$CONFIG_DIR" ]; then
certbot_params+=(--config-dir "$CONFIG_DIR")
fi

if [ ! -z "$WORK_DIR" ]; then
certbot_params+=(--work-dir "$WORK_DIR")
fi

if [ ! -z "$LOGS_DIR" ]; then
certbot_params+=(--logs-dir "$LOGS_DIR")
fi

if [ ! -z "$SERVER" ]; then
certbot_params+=(--server "$SERVER")
fi
Expand Down
48 changes: 48 additions & 0 deletions scripts/certbot-renew.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,55 @@
#!/bin/bash
set -e

while [[ $# > 0 ]]; do
case "$1" in
--cert-name)
CERT_NAME="$2"
shift # past argument
shift # past value
;;

-q|--quiet)
QUIET=1
shift # past argument
;;

--pre-hook)
PRE_HOOK_CMD="$2"
shift # past argument
shift # past value
;;

--post-hook)
POST_HOOK_CMD="$2"
shift # past argument
shift # past value
;;

--deploy-hook)
DEPLOY_HOOK_CMD="$2"
shift # past argument
shift # past value
;;

--certbot-renew-flags)
CERTBOT_RENEW_FLAGS=$2
shift # past argument
shift # past value
;;

*)
>&2 echo "Unknown command line option: $1"
exit 1
esac
done

# add manage certificates options
# only required if we should renew multiple certificates in an individual way
if [ ! -z "$CERT_NAME" ] && [ ! -z "$ENABLE_MULTI_CERTIFICATES" ]; then
certbot_params+=(--cert-name "$CERT_NAME")
fi

if [ ! -z "$QUIET" ]; then
certbot_params+=(-q)
fi
Expand Down
18 changes: 13 additions & 5 deletions scripts/configure-crontab.sh
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
#!/bin/bash
set -e

cron_script="/scripts/certbot-renew.sh"
crontab -l | grep -q "$CRON $cron_script" && ec=$? || ec=$?
# check if crontab already exists
cron_script="/usr/bin/flock /tmp/certbot-renew.lock /scripts/certbot-renew.sh"

# append command line options to renewal script if available
if [[ $# > 0 ]]; then
cron_exp="$CRON $cron_script $@"
else
cron_exp="$CRON $cron_script"
fi

# add renew crontab if not already exists
crontab -l | grep -q "$cron_exp" && ec=$? || ec=$?
if [ $ec == 0 ]; then
echo "crontab $CRON $cron_script already exists"
echo "crontab $cron_exp already exists"
else
echo "$CRON $cron_script" | crontab -
echo "$cron_exp" >> /etc/crontabs/root
fi
26 changes: 17 additions & 9 deletions scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,23 @@ set -e
# environment variable if not already installed
./scripts/install-dns-plugins.sh

# run certbot's certonly command to issue certificates if not already exists
./scripts/certbot-certonly.sh
if [ -z "$ENABLE_MULTI_CERTIFICATES" ]; then
# run certbot's certonly command to obtain certificates if not already exists
./scripts/certbot-certonly.sh

if [ -z "$RUN_ONCE" ]; then
# add crontab which is defined in the CRON
# environment variable if not already exists
./scripts/configure-crontab.sh
if [ -z "$RUN_ONCE" ]; then
# add crontab which is defined in the CRON
# environment variable if not already exists
./scripts/configure-crontab.sh

# execute the cron which we have configured previously
# to ensure that the issued certificates will be renewed
exec crond -f -L /var/log/letsencrypt/cron.log
# execute the crontab which we have configured previously
# to ensure that the obtained certificates will be renewed
exec crond -f -L /var/log/letsencrypt/cron.log
fi
else
# parse the INI file defined in the MULTI_CERTIFICATES_INI_FILE environment variable
# and obtain a certificate for each configured domain. if the RUN_ONCE
# environment variable is undefined, run configure-crontab.sh for each configured
# domain, too. after configuring the crontabs, crond is started
exec ./scripts/manage-multi-certificates.py
fi
60 changes: 60 additions & 0 deletions scripts/manage-multi-certificates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
import os
import configparser as cp
import subprocess as sp

ini_file = os.environ['MULTI_CERTIFICATES_INI_FILE']
# check if the file exists because the config parser ignores any errors
# when opening and reading a file respectively
if not os.path.exists(ini_file):
raise FileNotFoundError(f'{ini_file} not exists')

config_parser = cp.ConfigParser()
# prevent config parser from making strings to lowercase
config_parser.optionxform = str
config_parser.read(ini_file)

for section in config_parser.sections():
# map ItemsView to dictionary
opts = os.environ.copy()
for key, val in config_parser.items(section):
opts[key] = val

sp.run(['./scripts/certbot-certonly.sh'], stderr=sp.STDOUT, env=opts)

# run configure-crontab.sh if the RUN_ONCE environment variable is undefined
if opts.get('RUN_ONCE', '') == '':
# prepare existing renew options
renew_opts = opts.copy()
# if the CERT_NAME environment variable is undefined,
# we have to set the first value in the DOMAINS environment variable as its value
# to keep the renew individual
if renew_opts.get('CERT_NAME', '') == '':
renew_opts['CERT_NAME'] = renew_opts['DOMAINS'].split(',')[0]

# collect existing renew options in a list
# where each element is passed as argument to the renew script
renew_opts_args = [f'--cert-name "{renew_opts["CERT_NAME"]}"']
if renew_opts.get('QUIET', '') != '':
renew_opts_args += ['-q']

if renew_opts.get('PRE_HOOK_CMD', '') != '':
renew_opts_args += [f'--pre-hook "{renew_opts["PRE_HOOK_CMD"]}"']

if renew_opts.get('POST_HOOK_CMD', '') != '':
renew_opts_args += [f'--post-hook "{renew_opts["POST_HOOK_CMD"]}"']

if renew_opts.get('DEPLOY_HOOK_CMD', '') != '':
renew_opts_args += [
f'--deploy-hook "{renew_opts["DEPLOY_HOOK_CMD"]}"']

if renew_opts.get('CERTBOT_RENEW_FLAGS', '') != '':
renew_opts_args += [
f'--certbot-renew-flags {renew_opts["CERTBOT_RENEW_FLAGS"]}']

renew_args = ['./scripts/configure-crontab.sh']
renew_args.extend(renew_opts_args)
sp.run(renew_args, stderr=sp.STDOUT, env=renew_opts)

if config_parser.defaults().get('RUN_ONCE', '') == '':
os.execvp('crond', ['crond', '-f', '-L', '/var/log/letsencrypt/cron.log'])

0 comments on commit cfb935a

Please sign in to comment.