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}" +}