From 0ba45bcb6a74dd04cf70afe521005bc1ee0efbfc Mon Sep 17 00:00:00 2001 From: Fredrik Ekre Date: Wed, 21 May 2025 23:31:51 +0200 Subject: [PATCH] Enable YAML configuration This patch allows configuring nginx-certbot with a config.yml file. In particular this allows to directly declare the certificates that should be requested by certbot with finer granularity compared to the automatic discovery based on the nginx config files. Main motivations: - Currently, since automatic discovery is implemented on a per file basis, all domain names in a file are attached ot all certificates in that file. This means that for e.g. ```nginx server { server_name example.com *.example.com; ssl_certificate /etc/letsencrypt/live/example-com/fullchain.pem; # [...] } server { server_name a.example.com; ssl_certificate /etc/letsencrypt/live/a-example-com/fullchain.pem; # [...] } ``` both `example-com` and `a-example-com` will contain the domain names `example.com`, `*.example.com`, and `a.example.com`. With this patch it is possible to instead do ```yaml certificates: - name: example-com domains: [example.com, *.example.com] - name: a-example-com domains: [a.example.com] ``` - Currently the authenticator credentials can't be specified on a per certificate basis (see e.g. #315). With this patch that is possible: ```yaml certbot: authenticator: dns-cloudflare certificates: - name: example-com domains: [example.com] credentials: /etc/letsencrypt/example-com-cloudflare.ini - name: example-se domains: [example.se] credentials: /etc/letsencrypt/example-se-cloudflare.ini ``` - Authenticator and key type can currently be specified on a per-certificate basis by naming them appropriately. This works okay, but it becomes a bit clunky to support more such per-certificate configurations (such as e.g. the elliptic curve or the authenticator credentials). This patch allows to directly specify everything for each certificate: ```yaml certbot: authenticator: dns-cloudflare key-type: ecdsa certificates: - name: example-com-rsa domains: [example.com] key-type: rsa - name: example-com domains: [example.com] ``` The file examples/config.yml is documented with all the various options that are enabled. --- README.md | 40 +++-- docs/good_to_know.md | 29 ++++ examples/config.yml | 62 +++++++ src/Dockerfile | 2 + src/scripts/create_dhparams.sh | 11 +- src/scripts/run_certbot.sh | 253 ++++++++++++++++++----------- src/scripts/run_local_ca.sh | 109 ++++++++----- src/scripts/start_nginx_certbot.sh | 23 ++- src/scripts/util.sh | 55 ++++++- 9 files changed, 423 insertions(+), 161 deletions(-) create mode 100644 examples/config.yml diff --git a/README.md b/README.md index b5bef9ce..79f3276e 100644 --- a/README.md +++ b/README.md @@ -62,25 +62,41 @@ instructions, from `@staticfloat`'s image, can be found function. -## Available Environment Variables +## Configuration + +The container can be configured using a YAML config file or with environment +variables. The config file takes precendence whenever a setting is specified +twice. The default location of the config file is +`/etc/nginx-certbot/config.yml` but can be customized with the environment +variable `NGINX_CERTBOT_CONFIG_FILE`. A documented example config file can be +found in the [`examples/`](./examples) folder. ### Required -- `CERTBOT_EMAIL`: Your e-mail address. Used by Let's Encrypt to contact you in case of security issues. + +| YAML key | Environment variable | Description | +| --------------- | -------------------- | ----------- | +| `certbot.email` | `CERTBOT_EMAIL` | Your e-mail address. Used by Let's Encrypt to contact you in case of security issues. | ### Optional -- `DHPARAM_SIZE`: The size of the [Diffie-Hellman parameters](./docs/good_to_know.md#diffie-hellman-parameters) (default: `2048`) -- `ELLIPTIC_CURVE`: The size/[curve][15] of the ECDSA keys (default: `secp256r1`) -- `RENEWAL_INTERVAL`: Time interval between certbot's [renewal checks](./docs/good_to_know.md#renewal-check-interval) (default: `8d`) -- `RSA_KEY_SIZE`: The size of the RSA encryption keys (default: `2048`) -- `STAGING`: Set to `1` to use Let's Encrypt's [staging servers](./docs/good_to_know.md#initial-testing) (default: `0`) -- `USE_ECDSA`: Set to `0` to have certbot use [RSA instead of ECDSA](./docs/good_to_know.md#ecdsa-and-rsa-certificates) (default: `1`) + +| YAML key | Environment variable | Description | +| -------------------------------- | -------------------- | ----------- | +| `nginx-certbot.renewal-interval` | `RENEWAL_INTERVAL` | Time interval between certbot's [renewal checks](./docs/good_to_know.md#renewal-check-interval) (default: `8d`) | +| `nginx-certbot.dhparam-size` | `DHPARAM_SIZE` | The size of the [Diffie-Hellman parameters](./docs/good_to_know.md#diffie-hellman-parameters) (default: `2048`) | +| `certbot.elliptic-curve` | `ELLIPTIC_CURVE` | The size/[curve][15] of the ECDSA keys (default: `secp256r1`) | +| `certbot.rsa-key-size` | `RSA_KEY_SIZE` | The size of the RSA encryption keys (default: `2048`) | +| `certbot.staging` | `STAGING` | Set to `1` to use Let's Encrypt's [staging servers](./docs/good_to_know.md#initial-testing) (default: `0`) | +| - | `USE_ECDSA` | Set to `0` to have certbot use [RSA instead of ECDSA](./docs/good_to_know.md#ecdsa-and-rsa-certificates) (default: `1`) | +| `certbot.key-type` | - | Certificate key type (default: `ecdsa` (or, if `USE_ECDSA=0`, `rsa`) | ### Advanced -- `CERTBOT_AUTHENTICATOR`: The [authenticator plugin](./docs/certbot_authenticators.md) to use when responding to challenges (default: `webroot`) -- `CERTBOT_DNS_PROPAGATION_SECONDS`: The number of seconds to wait for the DNS challenge to [propagate](.docs/certbot_authenticators.md#troubleshooting-tips) (default: certbot's default) -- `DEBUG`: Set to `1` to enable debug messages and use the [`nginx-debug`][10] binary (default: `0`) -- `USE_LOCAL_CA`: Set to `1` to enable the use of a [local certificate authority](./docs/advanced_usage.md#local-ca) (default: `0`) +| YAML key | Environment variable | Description | +| --------------------------------- | --------------------------------- | ----------- | +| `certbot.authenticator` | `CERTBOT_AUTHENTICATOR` | The [authenticator plugin](./docs/certbot_authenticators.md) to use when responding to challenges (default: `webroot`) | +| `certbot.dns-propagation-seconds` | `CERTBOT_DNS_PROPAGATION_SECONDS` | The number of seconds to wait for the DNS challenge to [propagate](.docs/certbot_authenticators.md#troubleshooting-tips) (default: certbot's default) | +| `nginx-certbot.debug` | `DEBUG` | Set to `1` to enable debug messages and use the [`nginx-debug`][10] binary (default: `0`) | +| - | `USE_LOCAL_CA` | Set to `1` to enable the use of a [local certificate authority](./docs/advanced_usage.md#local-ca) (default: `0`) | ## Volumes - `/etc/letsencrypt`: Stores the obtained certificates and the Diffie-Hellman parameters diff --git a/docs/good_to_know.md b/docs/good_to_know.md index 2db2840f..093be49b 100644 --- a/docs/good_to_know.md +++ b/docs/good_to_know.md @@ -65,6 +65,13 @@ in the old way like how [`@staticfloat`'s image][5] worked. ## How the Script add Domain Names to Certificate Requests +There are two ways to configure the certificates the container should request +and maintain: + - [Automatic discovery](#automatic-certificate-discovery) based on the mounted + Nginx config files + - Explicit specification using the [YAML config file](#yaml-certificate-specification) + +### Automatic certificate discovery The included script will go through all configuration files (`*.conf*`) it finds inside Nginx's `/etc/nginx/conf.d/` folder, and create requests from the file's content. In every unique file it will find any line that says: @@ -119,6 +126,28 @@ Furthermore, we support wildcard domain names, but that requires you to use an authenticator capable of DNS-01 challenges, and more info about that may be found in the [certbot_authenticators.md](./certbot_authenticators.md) document. +### YAML certificate specification +To explicitly define certificate requests you can define a list `certificates:` +list in a YAML config file (`/etc/nginx-certbot/config.yml` by default). Note +that when the `certificates` key exist the automatic discovery from nginx +config files is disabled and *only* certificates from the config file are +requested. + +The example from the previous section would correspond to the following +specification: +```yaml +certificates: + - name: test-name + domains: + - yourdomain.com + - www.yourdomain.com + - sub.yourdomain.com +``` + +Refer to the commented example [`config.yml`](../examples/config.yml) file for +more details. It is, for example, possible to specify the +[certbot authenticator](./certbot_authenticators.md), and the certificate +[key type](#ecdsa-and-rsa-certificates). ## ECDSA and RSA Certificates [ECDSA (or ECC)][16] certificates use a newer encryption algorithm than the well diff --git a/examples/config.yml b/examples/config.yml new file mode 100644 index 00000000..9110848c --- /dev/null +++ b/examples/config.yml @@ -0,0 +1,62 @@ +# Configuration for this docker image +nginx-certbot: + # Diffie-Hellman parameter size. Falls back to the DHPARAM_SIZE environment variable or, + # if that is unset, to '2048'. + dhparam-size: 2048 + # Certificate renewal interval. Falls back to the RENEWAL_INTERVAL environment variable + # or, if that is unset, to '8d'. + renewal-interval: 8d + # Boolean to enable verbose debug messages and the nginx-debug binary. Falls back to the + # DEBUG environment variable, or, if that is unset, to 'false'. + debug: false + +# Configuration for certbot. +# Note that some of these can be overriden on the certificate level. +certbot: + # Default certbot authenticator (see certbots --authenticator flag). Falls back to the + # CERTBOT_AUTHENTICATOR environment variable or, if that is unset, to 'webroot'. The + # authenticator can be overriden on the certificate level. + authenticator: webroot + # Default certbot authenticator credentials (see certbots ---credentials + # flag). This is required for the various DNS authenticators. Falls back to + # '/etc/letsencrypt/.ini'. + credentials: '' + # Number of seconds to wait for the DNS challenge (when using dns authenticators). Falls + # back to the CERTBOT_DNS_PROPAGATION_SECONDS environment variable and if that is unset to + # certbots default. + dns-propagation-seconds: '' + # Default elliptic curve (see certbots --elliptic-curve flag). Falls back to the + # ELLIPTIC_CURVE environment variable or, if that is unset, to 'secp256r1'. + elliptic-curve: secp256r1 + # Default key type (see certbots --key-type flag). Falls back to 'ecdsa' (or if + # USE_ECDSA=0 to 'rsa'). The key type can be overriden on the certificate level. + key-type: ecdsa + # Default RSA key size (see certbots --rsa-key-size flag). Falls back to the RSA_KEY_SIZE + # environment variable or, if that is unset, to 2048. The key size can be overriden on the + # certificate level. + rsa-key-size: 2048 + # Boolean to enable the Let's Encrypt staging servers. Falls back to the STAGING + # environment variable or, if that is unset, to 'false'. + staging: false + +# Array of certificate specifications. +# If the 'certificates' key exist (even if the array is empty) the automatic discovery of +# certificate names and domains is disabled and instead nginx-certbot will request +# certificates based on the specifications in the array. +# A minimum requirement for each certificate is to specifiy 'name' and 'domains'. +certificates: + # Certificate name (see certbots --cert-name flag). Generated certificates will be + # placed in the /etc/letsencrypt/live// folder. This is a required parameter. + - name: example-com + # Required list of domains for which the certificate should be valid for (see certbots + # --domain flag). This is a required parameter. + domains: ["a.example.com", "b.example.com", "*.c.example.com"] + # Authenticator to use for this certificate. Falls back to certbot.authenticator. + authenticator: '' + # Credential file for this certificates authenticator. Falls back to + # certbot.credentials. + credentials: '' + # Key type for the certificate. Falls back to certbot.key-type. + key-type: '' + # RSA key size for the certificate. Falls back to certbot.rsa-key-size. + rsa-key-size: '' diff --git a/src/Dockerfile b/src/Dockerfile index 6ccd9c85..6a182633 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -71,6 +71,8 @@ RUN set -ex && \ pip3 install -r /requirements.txt && \ # And the supported extra authenticators. pip3 install $(echo $CERTBOT_DNS_AUTHENTICATORS | sed 's/\(^\| \)/\1certbot-dns-/g') && \ +# Install shyaml + pip3 install shyaml && \ # Remove everything that is no longer necessary. apt-get remove --purge -y \ build-essential \ diff --git a/src/scripts/create_dhparams.sh b/src/scripts/create_dhparams.sh index 684d76fa..a5ddb6e8 100644 --- a/src/scripts/create_dhparams.sh +++ b/src/scripts/create_dhparams.sh @@ -11,16 +11,13 @@ set -e # The created file should be stored somewhere under /etc/letsencrypt/dhparams/ # to ensure persistence between restarts. create_dhparam() { - if [ -z "${DHPARAM_SIZE}" ]; then - debug "DHPARAM_SIZE unset, using default of 2048 bits" - DHPARAM_SIZE=2048 - fi - + local dhparam_size + dhparam_size=$(get_config nginx-certbot.dhparam-size DHPARAM_SIZE 2048 "Diffie-Hellman parameter size") info " %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% % ATTENTION! % % % - % This script will now create a ${DHPARAM_SIZE} bit Diffie-Hellman % + % This script will now create a ${dhparam_size} bit Diffie-Hellman % % parameter to use during the SSL handshake. % % % % >>>>> This MIGHT take a VERY long time! <<<<< % @@ -34,7 +31,7 @@ create_dhparam() { %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% " info "Will now output to the following file: '${1}'" - openssl dhparam -out "${1}" "${DHPARAM_SIZE}" + openssl dhparam -out "${1}" "${dhparam_size}" info " %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% % >>>>> Diffie-Hellman parameter creation done! <<<<< % diff --git a/src/scripts/run_certbot.sh b/src/scripts/run_certbot.sh index 5f6241a1..bfd4a22b 100644 --- a/src/scripts/run_certbot.sh +++ b/src/scripts/run_certbot.sh @@ -1,43 +1,45 @@ #!/bin/bash set -e -# URLs used when requesting certificates. -# These are picked up from the environment if they are set, which enables -# advanced usage of custom ACME servers, else it will use the default Let's -# Encrypt servers defined here. -: "${CERTBOT_PRODUCTION_URL=https://acme-v02.api.letsencrypt.org/directory}" -: "${CERTBOT_STAGING_URL=https://acme-staging-v02.api.letsencrypt.org/directory}" - # Source in util.sh so we can have our nice tools. . "$(cd "$(dirname "$0")"; pwd)/util.sh" info "Starting certificate renewal process" +# Lookup config for debug mode here too since this script is sometimes run as a standalone +# script with the "force" argument. +DEBUG=$(get_config nginx-certbot.debug DEBUG 0 "debug mode") +export DEBUG + +# Load the configuration +certbot_email=$(get_config certbot.email CERTBOT_EMAIL '' "certbot email") +certbot_authenticator=$(get_config certbot.authenticator CERTBOT_AUTHENTICATOR webroot "default certbot authenticator") +certbot_elliptic_curve=$(get_config certbot.elliptic-curve ELLIPTIC_CURVE secp256r1 "certbot elliptic curve") +certbot_key_type=$(get_config certbot.key-type '' "$( [ "${USE_ECDSA}" == "0" ] && echo "rsa" || echo "ecdsa")" "default certbot key type") +certbot_rsa_key_size=$(get_config certbot.rsa-key-size RSA_KEY_SIZE 2048 "default certbot RSA key size") +certbot_staging=$(get_config certbot.staging STAGING 0 "certbot staging") +certbot_dns_propagation=$(get_config certbot.dns-propagation-seconds CERTBOT_DNS_PROPAGATION_SECONDS '' "DNS propagation timeout") + +# URLs used when requesting certificates. +# These are picked up from the environment if they are set, which enables +# advanced usage of custom ACME servers, else it will use the default Let's +# Encrypt servers defined here. +certbot_production_url=$(get_config certbot.production-url CERTBOT_PRODUCTION_URL "https://acme-v02.api.letsencrypt.org/directory" "certbot production URL") +certbot_staging_url=$(get_config certbot.staging-url CERTBOT_STAGING_URL "https://acme-staging-v02.api.letsencrypt.org/directory" "certbot staging URL") + # We require an email to be able to request a certificate. -if [ -z "${CERTBOT_EMAIL}" ]; then - error "CERTBOT_EMAIL environment variable undefined; certbot will do nothing!" +if [ -z "${certbot_email}" ]; then + error "certbot.email or the CERTBOT_EMAIL environment variable must be set; without it certbot will do nothing!" exit 1 fi # Use the correct challenge URL depending on if we want staging or not. -if [ "${STAGING}" = "1" ]; then - debug "Using staging environment" - letsencrypt_url="${CERTBOT_STAGING_URL}" +if [ "${certbot_staging}" = "1" ]; then + debug "Using staging environment (${certbot_staging_url})" + letsencrypt_url="${certbot_staging_url}" else - debug "Using production environment" - letsencrypt_url="${CERTBOT_PRODUCTION_URL}" -fi - -# Ensure that an RSA key size is set. -if [ -z "${RSA_KEY_SIZE}" ]; then - debug "RSA_KEY_SIZE unset, defaulting to 2048" - RSA_KEY_SIZE=2048 -fi - -# Ensure that an elliptic curve is set. -if [ -z "${ELLIPTIC_CURVE}" ]; then - debug "ELLIPTIC_CURVE unset, defaulting to 'secp256r1'" - ELLIPTIC_CURVE="secp256r1" + debug "Using production environment (${certbot_production_url})" + letsencrypt_url="${certbot_production_url}" fi if [ "${1}" = "force" ]; then @@ -53,8 +55,17 @@ fi # $2: String with all requested domains (e.g. -d domain.org -d www.domain.org) # $3: Type of key algorithm to use (rsa or ecdsa) # $4: The authenticator to use to solve the challenge +# $5: The RSA key size (--rsa-key-size) +# $6: The elliptic curve (--elliptic-curve) +# $7: Credentials file for the authenticator get_certificate() { + local cert_name="${1}" + local domain_request="${2}" + local key_type="${3}" local authenticator="${4,,}" + local rsa_key_size="${5:-certbot_rsa_key_size}" + local elliptic_curve="${6:-certbot_elliptic_curve}" + local credentials="${7}" local authenticator_params="" local challenge_type="" @@ -72,98 +83,154 @@ get_certificate() { return 1 fi else - local configfile="/etc/letsencrypt/${authenticator#dns-}.ini" + local configfile="${credentials:-/etc/letsencrypt/${authenticator#dns-}.ini}" if [ ! -f "${configfile}" ]; then - error "Authenticator is '${authenticator}' but '${configfile}' is missing" + error "Authenticator '${authenticator}' requires credentials but '${configfile}' is missing" return 1 fi authenticator_params="--${authenticator}-credentials=${configfile}" fi - if [ -n "${CERTBOT_DNS_PROPAGATION_SECONDS}" ]; then - authenticator_params="${authenticator_params} --${authenticator}-propagation-seconds=${CERTBOT_DNS_PROPAGATION_SECONDS}" + if [ -n "${certbot_dns_propagation}" ]; then + authenticator_params="${authenticator_params} --${authenticator}-propagation-seconds=${certbot_dns_propagation}" fi else - error "Unknown authenticator '${authenticator}' for '${1}'" + error "Unknown authenticator '${authenticator}' for '${cert_name}'" return 1 fi - info "Requesting an ${3^^} certificate for '${1}' (${challenge_type} through ${authenticator})" + info "Requesting an ${key_type^^} certificate for '${cert_name}' (${challenge_type} through ${authenticator})" certbot certonly \ --agree-tos --keep -n --text \ --preferred-challenges ${challenge_type} \ --authenticator ${authenticator} \ ${authenticator_params} \ - --email "${CERTBOT_EMAIL}" \ + --email "${certbot_email}" \ --server "${letsencrypt_url}" \ - --rsa-key-size "${RSA_KEY_SIZE}" \ - --elliptic-curve "${ELLIPTIC_CURVE}" \ - --key-type "${3}" \ - --cert-name "${1}" \ - ${2} \ + --rsa-key-size "${rsa_key_size}" \ + --elliptic-curve "${elliptic_curve}" \ + --key-type "${key_type}" \ + --cert-name "${cert_name}" \ + ${domain_request} \ --debug ${force_renew} } # Get all the cert names for which we should create certificate requests and # have them signed, along with the corresponding server names. -# -# This will return an associative array that looks something like this: -# "cert_name" => "server_name1 server_name2" -declare -A certificates -for conf_file in /etc/nginx/conf.d/*.conf*; do - parse_config_file "${conf_file}" certificates -done - -# Iterate over each key and make a certificate request for them. -for cert_name in "${!certificates[@]}"; do - server_names=(${certificates["$cert_name"]}) - - # Determine which type of key algorithm to use for this certificate - # request. Having the algorithm specified in the certificate name will - # take precedence over the environmental variable. - if [[ "${cert_name,,}" =~ (^|[-.])ecdsa([-.]|$) ]]; then - debug "Found variant of 'ECDSA' in name '${cert_name}" - key_type="ecdsa" - elif [[ "${cert_name,,}" =~ (^|[-.])ecc([-.]|$) ]]; then - debug "Found variant of 'ECC' in name '${cert_name}" - key_type="ecdsa" - elif [[ "${cert_name,,}" =~ (^|[-.])rsa([-.]|$) ]]; then - debug "Found variant of 'RSA' in name '${cert_name}" - key_type="rsa" - elif [ "${USE_ECDSA}" == "0" ]; then - key_type="rsa" - else - key_type="ecdsa" - fi +# If we have a config file with the 'certificates' key we request certificates based on the +# specifications within that file otherwise we parse the nginx config files to automatically +# discover certificate names, key types, authenticators, and domains. +if [ -f "${CONFIG_FILE}" ] && shyaml -q get-value certificates >/dev/null <"${CONFIG_FILE}"; then + debug "Using config file '${CONFIG_FILE}' for certificate specifications" + # Loop over the certificates array and request the certificates + while read -r -d '' cert; do + debug "Parsing certificate specification" + + # name (required) + cert_name="$(shyaml get-value name '' <<<"${cert}")" + if [ -z "${cert_name}" ]; then + error "'name' is missing; ignoring this certificate specification" + continue + fi + debug " - certificate name is: ${cert_name}" + + # domains (required) + domains=() + while read -r -d '' domain; do + domains+=("${domain}") + done < <(shyaml get-values-0 domains '' <<<"${cert}") + if [ "${#domains[@]}" -eq 0 ]; then + error "'domains' are missing; ignoring this certificate specification" + continue + fi + debug " - certificate domains are: ${domains[*]}" + domain_request="" + for domain in "${domains[@]}"; do + domain_request+=" --domain ${domain}" + done + + # key-type (optional) + key_type=$(shyaml get-value key-type "${certbot_key_type}" <<<"${cert}") + debug " - certificate key-type is: ${key_type}" + + # authenticator (optional) + authenticator=$(shyaml get-value authenticator "${certbot_authenticator}" <<<"${cert}") + debug " - certificate authenticator is: ${authenticator}" + + # credentials (optional) + credentials=$(shyaml get-value credentials '' <<<"${cert}") + debug " - certificate authenticator credential file is: ${credentials}" + + # rsa-key-size (optional) + rsa_key_size=$(shyaml get-value rsa-key-size "${certbot_rsa_key_size}" <<<"${cert}") + debug " - certificate RSA key size is: ${rsa_key_size}" + + # elliptic-curve (optional) + elliptic_curve=$(shyaml get-value elliptic-curve "${certbot_elliptic_curve}" <<<"${cert}") + debug " - certificate elliptic curve is: ${elliptic_curve}" + + # Hand over all the info required for the certificate request, and + # let certbot decide if it is necessary to update the certificate. + if ! get_certificate "${cert_name}" "${domain_request}" "${key_type}" "${authenticator}" "${rsa_key_size}" "${elliptic_curve}" "${credentials}"; then + error "Certbot failed for '${cert_name}'. Check the logs for details." + fi + done < <(shyaml -y get-values-0 certificates '' <"${CONFIG_FILE}") +else + debug "Using automatic discovery of nginx conf file for certificate specifications" + # This will return an associative array that looks something like this: + # "cert_name" => "server_name1 server_name2" + declare -A certificates + for conf_file in /etc/nginx/conf.d/*.conf*; do + parse_config_file "${conf_file}" certificates + done - # Determine the authenticator to use to solve the authentication challenge. - # Having the authenticator specified in the certificate name will take - # precedence over the environmental variable. - if [[ "${cert_name,,}" =~ (^|[-.])webroot([-.]|$) ]]; then - authenticator="webroot" - debug "Found mention of 'webroot' in name '${cert_name}" - elif [[ "${cert_name,,}" =~ (^|[-.])(dns-($(echo ${CERTBOT_DNS_AUTHENTICATORS} | sed 's/ /|/g')))([-.]|$) ]]; then - authenticator=${BASH_REMATCH[2]} - debug "Found mention of authenticator '${authenticator}' in name '${cert_name}'" - elif [ -n "${CERTBOT_AUTHENTICATOR}" ]; then - authenticator="${CERTBOT_AUTHENTICATOR}" - else - authenticator="webroot" - fi + # Iterate over each key and make a certificate request for them. + for cert_name in "${!certificates[@]}"; do + server_names=(${certificates["$cert_name"]}) + + # Determine which type of key algorithm to use for this certificate + # request. Having the algorithm specified in the certificate name will + # take precedence over the environmental variable. + if [[ "${cert_name,,}" =~ (^|[-.])ecdsa([-.]|$) ]]; then + debug "Found variant of 'ECDSA' in name '${cert_name}" + key_type="ecdsa" + elif [[ "${cert_name,,}" =~ (^|[-.])ecc([-.]|$) ]]; then + debug "Found variant of 'ECC' in name '${cert_name}" + key_type="ecdsa" + elif [[ "${cert_name,,}" =~ (^|[-.])rsa([-.]|$) ]]; then + debug "Found variant of 'RSA' in name '${cert_name}" + key_type="rsa" + else + key_type="${certbot_key_type}" + fi - # Assemble the list of domains to be included in the request from - # the parsed 'server_names' - domain_request="" - for server_name in "${server_names[@]}"; do - domain_request="${domain_request} -d ${server_name}" - done + # Determine the authenticator to use to solve the authentication challenge. + # Having the authenticator specified in the certificate name will take + # precedence over the environmental variable. + if [[ "${cert_name,,}" =~ (^|[-.])webroot([-.]|$) ]]; then + authenticator="webroot" + debug "Found mention of 'webroot' in name '${cert_name}" + elif [[ "${cert_name,,}" =~ (^|[-.])(dns-($(echo ${CERTBOT_DNS_AUTHENTICATORS} | sed 's/ /|/g')))([-.]|$) ]]; then + authenticator=${BASH_REMATCH[2]} + debug "Found mention of authenticator '${authenticator}' in name '${cert_name}'" + else + authenticator="${certbot_authenticator}" + fi - # Hand over all the info required for the certificate request, and - # let certbot decide if it is necessary to update the certificate. - if ! get_certificate "${cert_name}" "${domain_request}" "${key_type}" "${authenticator}"; then - error "Certbot failed for '${cert_name}'. Check the logs for details." - fi -done + # Assemble the list of domains to be included in the request from + # the parsed 'server_names' + domain_request="" + for server_name in "${server_names[@]}"; do + domain_request="${domain_request} -d ${server_name}" + done + + # Hand over all the info required for the certificate request, and + # let certbot decide if it is necessary to update the certificate. + if ! get_certificate "${cert_name}" "${domain_request}" "${key_type}" "${authenticator}"; then + error "Certbot failed for '${cert_name}'. Check the logs for details." + fi + done +fi # After trying to get all our certificates, auto enable any configs that we # did indeed get certificates for. diff --git a/src/scripts/run_local_ca.sh b/src/scripts/run_local_ca.sh index 38e903ca..d4733663 100644 --- a/src/scripts/run_local_ca.sh +++ b/src/scripts/run_local_ca.sh @@ -16,19 +16,17 @@ LOCAL_CA_CRT_DIR="${LOCAL_CA_DIR}/new_certs" info "Starting certificate renewal process with local CA" +# Load some configuration from file with environment variables as fallback +certbot_email=$(get_config certbot.email CERTBOT_EMAIL '' "certbot email") +certbot_rsa_key_size=$(get_config certbot.rsa-key-size RSA_KEY_SIZE 2048 "RSA key size") + # We require an email to be set here as well, in order to simulate how it would # be in the real certbot case. -if [ -z "${CERTBOT_EMAIL}" ]; then - error "CERTBOT_EMAIL environment variable undefined; local CA will do nothing!" +if [ -z "${certbot_email}" ]; then + error "certbot.email or the CERTBOT_EMAIL environment variable must be set; without it certbot will do nothing!" exit 1 fi -# Ensure that an RSA key size is set. -if [ -z "${RSA_KEY_SIZE}" ]; then - debug "RSA_KEY_SIZE unset, defaulting to 2048" - RSA_KEY_SIZE=2048 -fi - # This is an OpenSSL configuration file that has settings for creating a well # configured CA, as well as server certificates that adhere to the strict # standards of web browsers. This is not complete, but will have the missing @@ -111,7 +109,7 @@ generate_ca() { # Make sure there is a private key available for the CA. if [ ! -f "${LOCAL_CA_KEY}" ]; then info "Generating new private key for local CA" - openssl genrsa -out "${LOCAL_CA_KEY}" "${RSA_KEY_SIZE}" + openssl genrsa -out "${LOCAL_CA_KEY}" "${certbot_rsa_key_size}" fi # Make sure there exists a self-signed certificate for the CA. @@ -136,7 +134,7 @@ generate_ca() { "0.organizationName = github.com/JonasAlfredsson" \ "organizationalUnitName = docker-nginx-certbot" \ "commonName = Local Debug CA" \ - "emailAddress = ${CERTBOT_EMAIL}" \ + "emailAddress = ${certbot_email}" \ ) \ -extensions ca_cert \ -days "${LOCAL_CA_ROOT_CERT_VALIDITY}" \ @@ -177,7 +175,7 @@ get_certificate() { # It is good practice to generate a new key every time a new certificate is # requested, in order to guard against potential key compromises. info "Generating new private key for '${cert_name}'" - openssl genrsa -out "/etc/letsencrypt/live/${cert_name}/privkey.pem" "${RSA_KEY_SIZE}" + openssl genrsa -out "/etc/letsencrypt/live/${cert_name}/privkey.pem" "${certbot_rsa_key_size}" # Create a certificate signing request from the private key. info "Generating certificate signing request for '${cert_name}'" @@ -185,7 +183,7 @@ get_certificate() { "${openssl_cnf}" \ "[ dn_section ]" \ "commonName = ${cert_name}" \ - "emailAddress = ${CERTBOT_EMAIL}" \ + "emailAddress = ${certbot_email}" \ ) \ -key "/etc/letsencrypt/live/${cert_name}/privkey.pem" \ -out "${LOCAL_CA_DIR}/${cert_name}.csr" @@ -217,42 +215,77 @@ get_certificate() { # time this script is invoked. generate_ca -# Get all the cert names for which we should create certificates for, along -# with the corresponding server names. -# -# This will return an associative array that looks something like this: -# "cert_name" => "server_name1 server_name2" -declare -A certificates -for conf_file in /etc/nginx/conf.d/*.conf*; do - parse_config_file "${conf_file}" certificates -done - -# Iterate over each key and create a signed certificate for them. -for cert_name in "${!certificates[@]}"; do - server_names=(${certificates["$cert_name"]}) - - # Assemble the list of domains to be included in the request. - ip_count=0 - dns_count=0 - alt_names=() +# Assemble the list of domains to be included in the request. +# $@: All domain name variants +assemble_alt_names() { + local server_names=("${@}") + local ip_count=0 + local dns_count=0 + local alt_names=() for server_name in "${server_names[@]}"; do if is_ip "${server_name}"; then # See if the alt name looks like an IP address. - ip_count=$((${ip_count} + 1)) + ip_count=$((ip_count + 1)) alt_names+=("IP.${ip_count}=${server_name}") else # Else we suppose this is a valid DNS name. - dns_count=$((${dns_count} + 1)) + dns_count=$((dns_count + 1)) alt_names+=("DNS.${dns_count}=${server_name}") fi done + echo "${alt_names[@]}" +} - # Hand over all the info required for the certificate request, and - # let the local CA handle the rest. - if ! get_certificate "${cert_name}" "${alt_names[@]}"; then - error "Local CA failed for '${cert_name}'. Check the logs for details." - fi -done +# Get all the cert names for which we should create certificates for, along +# with the corresponding server names. +if [ -f "${CONFIG_FILE}" ] && shyaml -q get-value certificates >/dev/null <"${CONFIG_FILE}"; then + debug "Using config file '${CONFIG_FILE}' for certificate specifications" + # Loop over the certificates array and request the certificates + while read -r -d '' cert; do + debug "Parsing certificate specification" + cert_name="$(shyaml get-value name '' <<<"${cert}")" + if [ -z "${cert_name}" ]; then + error "'name' is missing; ignoring this certificate specification" + continue + fi + debug " - certificate name is: ${cert_name}" + domains=() + while read -r -d '' domain; do + domains+=("${domain}") + done < <(shyaml get-values-0 domains '' <<<"${cert}") + if [ "${#domains[@]}" -eq 0 ]; then + error "'domains' are missing; ignoring this certificate specification" + continue + fi + debug " - certificate domains are: ${domains[*]}" + # Assemble the list of domains to be included in the request. + read -ra alt_names < <(assemble_alt_names "${domains[@]}") + # Hand over all the info required for the certificate request, and + # let the local CA handle the rest. + if ! get_certificate "${cert_name}" "${alt_names[@]}"; then + error "Local CA failed for '${cert_name}'. Check the logs for details." + fi + done < <(shyaml -y get-values-0 certificates '' <"${CONFIG_FILE}") +else + debug "Using automatic discovery of nginx conf file for certificate specifications" + # This will return an associative array that looks something like this: + # "cert_name" => "server_name1 server_name2" + declare -A certificates + for conf_file in /etc/nginx/conf.d/*.conf*; do + parse_config_file "${conf_file}" certificates + done + # Iterate over each key and create a signed certificate for them. + for cert_name in "${!certificates[@]}"; do + server_names=(${certificates["$cert_name"]}) + # Assemble the list of domains to be included in the request. + read -ra alt_names < <(assemble_alt_names "${server_names[@]}") + # Hand over all the info required for the certificate request, and + # let the local CA handle the rest. + if ! get_certificate "${cert_name}" "${alt_names[@]}"; then + error "Local CA failed for '${cert_name}'. Check the logs for details." + fi + done +fi # After trying to sign all of the certificates, auto enable any configs that we # did indeed succeed with. diff --git a/src/scripts/start_nginx_certbot.sh b/src/scripts/start_nginx_certbot.sh index a9d9fae6..ef3729c7 100644 --- a/src/scripts/start_nginx_certbot.sh +++ b/src/scripts/start_nginx_certbot.sh @@ -21,9 +21,25 @@ trap "clean_exit" EXIT # Source "util.sh" so we can have our nice tools. . "$(cd "$(dirname "$0")"; pwd)/util.sh" -# If the environment variable `DEBUG=1` is set, then this message is printed. +# Enable debug mode if requested and export the variable to the various subprocesses +DEBUG=$(get_config nginx-certbot.debug DEBUG 0 "debug mode") +export DEBUG +# If `DEBUG=1` is set, then this message is printed. debug "Debug messages are enabled" +# Configuration file from NGINX_CERTBOT_CONFIG_FILE environment variable. We make some noise +# here during startup if the variable is set to a file that doesn't exist since this is most +# likely a user error. +if [ ! -f "${CONFIG_FILE}" ]; then + if [ -n "${NGINX_CERTBOT_CONFIG_FILE}" ]; then + warning "NGINX_CERTBOT_CONFIG_FILE is explicitly set but '${CONFIG_FILE}' doesn't exist." + else + debug "Configuration file '${CONFIG_FILE}' doesn't exist." + fi +else + debug "Configuration file '${CONFIG_FILE}' exist." +fi + # Immediately symlink files to the correct locations and then run # 'auto_enable_configs' so that Nginx is in a runnable state # This will temporarily disable any misconfigured servers. @@ -43,10 +59,7 @@ fi debug "PID of the main Nginx process: ${NGINX_PID}" # Make sure a renewal interval is set before continuing. -if [ -z "${RENEWAL_INTERVAL}" ]; then - debug "RENEWAL_INTERVAL unset, using default of '8d'" - RENEWAL_INTERVAL='8d' -fi +RENEWAL_INTERVAL=$(get_config nginx-certbot.renewal-interval RENEWAL_INTERVAL 8d "renewal interval") # Instead of trying to run 'cron' or something like that, just sleep and # call on certbot after the defined interval. diff --git a/src/scripts/util.sh b/src/scripts/util.sh index 81f8f7d6..a7c7be26 100644 --- a/src/scripts/util.sh +++ b/src/scripts/util.sh @@ -1,5 +1,8 @@ #!/bin/bash +# Configuration file with default location +CONFIG_FILE="${NGINX_CERTBOT_CONFIG_FILE:-/etc/nginx-certbot/config.yml}" + : ${DATE_FORMAT_STRING:="+%Y/%m/%d %T"} # Helper function used to output messages in a uniform manner. @@ -10,31 +13,31 @@ log() { echo "$(date "${DATE_FORMAT_STRING}") [${1}] ${2}" } -# Helper function to output debug messages to STDOUT if the `DEBUG` environment +# Helper function to output debug messages to STDERR if the `DEBUG` environment # variable is set to 1. # # $1: String to be printed. debug() { if [ 1 = "${DEBUG}" ]; then - log "debug" "${1}" + (log "debug" "${1}") >&2 fi } -# Helper function to output informational messages to STDOUT. +# Helper function to output informational messages to STDERR. # # $1: String to be printed. info() { - log "info" "${1}" + (log "info" "${1}") >&2 } -# Helper function to output warning messages to STDOUT, with bold yellow text. +# Helper function to output warning messages to STDERR, with bold yellow text. # # $1: String to be printed. warning() { (set +x; tput -Tscreen bold tput -Tscreen setaf 3 log "warning" "${1}" - tput -Tscreen sgr0) + tput -Tscreen sgr0) >&2 } # Helper function to output error messages to STDERR, with bold red text. @@ -304,3 +307,43 @@ auto_enable_configs() { fi done } + +# Helper function to lookup configuration from the YAML config file and environment variables. +# +# $1: YAML key +# $2: Environment variable +# $3: Default value +# $4: Setting name (for pretty debug printing) +get_config () { + local yml_key=${1} + local env_var=${2} + local default=${3} + local setting_name=${4} + local value="" + local msg="Looking up config for ${setting_name}:" + # First look in the config file... + if [ -f "${CONFIG_FILE}" ]; then + value="$(shyaml get-value "${yml_key}" '' <"${CONFIG_FILE}")" + if [ -n "${value}" ]; then + # Normalize booleans to `1` and `0` (shyaml will normalize all valid YAML + # booleans to 'True' and 'False' so only need to check for that). + if [ "$(shyaml -q get-type "${yml_key}" <"${CONFIG_FILE}")" == "bool" ]; then + [ "${value}" = "True" ] && value="1" || value="0" + fi + debug "${msg} using ${yml_key}=${value} from config file." + fi + fi + # ...then fall back to the environment variable... + if [ -z "${value}" ] && [ -n "${env_var}" ] && [ -n "${!env_var}" ]; then + value="${!env_var}" + if [ -n "${value}" ]; then + debug "${msg} using ${env_var}=${value} from environment" + fi + fi + # ...and finally to the default value. + if [ -z "${value}" ]; then + value="${default}" + debug "${msg} using default value (${value})." + fi + echo -n "${value}" +}