diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..368ce06 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM docker.io/cisagov/postfix:latest + +ENV ENV_FILE "" +ENV DKIM_DELAY "30" +ENV DNS_PROPAGATION_DELAY "30" +ENV RENEW_INTERVAL_IN_DAYS "7" +ENV TEST_MODE "1" + +ARG PROVIDER="aws" + +RUN mkdir -p certbot /usr/local/share/certs/providers /usr/local/share/certs/scripts /run/secrets +COPY providers/"${PROVIDER}".bash /usr/local/share/certs/providers +COPY scripts/*.bash /usr/local/share/certs/scripts + +RUN apt-get update \ + && \ + apt install -y \ + certbot \ + jq \ + procps \ + psmisc \ + && \ + bash -c " \ + source /usr/local/share/certs/providers/${PROVIDER}.bash \ + && \ + provider_dependencies \ + " \ + && \ + rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh entrypoint.sh +RUN chmod +x entrypoint.sh + +ENTRYPOINT ["./entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f89d31e --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# SMTP Docker Container + +Wraps [cisagov/postfix-docker](https://github.com/cisagov/postfix-docker) in automation for generating [Let's Encrypt](https://letsencrypt.org/) SSL certificates and dkim DNS records. + +## Build Arguments + +| Name | Value | Default | +|----------|-----------------------------------------------|---------| +| PROVIDER | "aws" or "cloudflare" to customize container. | aws | + +## Environment Variables + +You may set the following environment variables to customize the container's behaviour: + +| Name | Value | Default | +|------------------------|--------------------------------------------------------------|------------| +| CONTACT_EMAIL | Let's Encrypt Contact Email. | No Default | +| DKIM_DELAY | The time to wait for opendkim to generate a dkim value. | 30 | +| DNS_PROPAGATION_DELAY | The time for Let's Encrypt to wait for DNS changes. | 30 | +| PRIMARY_DOMAIN | The domain postfix is running for. | No Default | +| RENEW_INTERVAL_IN_DAYS | The interval (in days) to attempt to renew the certificates. | 7 | +| TEST_MODE | Set to "0" after you have tested certificate generation. | 1 | + +### DNS Providers + +Each DNS provider has its own set of additional required environment variables. + +#### AWS DNS Provider + +There are no defaults for provider environment variables. + +| Name | Value | +|-----------------------|-------------------------------------------------| +| AWS_ACCESS_KEY_ID | The AWS access key to use to access Route 53. | +| AWS_HOSTED_ZONE_ID | The AWS ID for the zone hosted in Route 53. | +| AWS_SECRET_ACCESS_KEY | The associated AWS secret key for that account. | + +Please see the [certbot plugin documentation](https://certbot-dns-route53.readthedocs.io/en/stable/) for further details. + +#### Cloudflare DNS Provider + +There are no defaults for provider environment variables. + +| Name | Value | +|----------------------|-----------------------------------------------------------------------------------------------------------------------------| +| CLOUDFLARE_API_TOKEN | The restricted Cloudflare API Token for this domain. | +| CLOUDFLARE_ZONE_ID | The [zone id](https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/) of the domain in Cloudflare. | + +Please see the [certbot plugin documentation](https://certbot-dns-cloudflare.readthedocs.io/en/stable/) for further details. + +### Using an Env File + +Alternatively, you can *mount* a single env file containing all required values. +This file should adhere to the standard Env File format: +```bash +ENV_NAME_1="ENV_VALUE_1" +ENV_NAME_2="ENV_VALUE_2" +ENV_NAME_3="ENV_VALUE_3" +``` + +Configure this environment variable to tell the container where to find the Env File: + +| Name | Value | Default | +|----------|--------------------------------------------------------|------------| +| ENV_FILE | Mounted location of the env file inside the container. | No Default | diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..a759c88 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +set -eo pipefail + +trap terminate SIGINT SIGTERM ERR EXIT + +env_file() { + if [[ -n "${ENV_FILE}" ]]; then + set -a + # shellcheck disable=SC1090 + source "${ENV_FILE}" + set +a + fi +} + +import () { + # $1 - path to scripts + # $2 - description of import + for SCRIPT in "${1}"/*.bash; do + echo "CONTAINER > Import ${2}: ${SCRIPT}" + # shellcheck disable=SC1090 + source "${SCRIPT}"; + done +} + +terminate() { + ERROR_CODE="$?" + echo "CONTAINER > ERROR CODE: ${ERROR_CODE}" + exit "${ERROR_CODE}" +} + +main() { + + env_file + + import /usr/local/share/certs/providers "DNS Provider" + import /usr/local/share/certs/scripts "Script Library" + + # shellcheck disable=SC2034 + if [[ "${TEST_MODE}" == "1" ]]; then + TEST_MODE="--test-cert" + else + TEST_MODE="" + fi + + create # Create initial certificates + renew & # Start certificate renewal process + dkim & # Start deferred dkim update process + + echo "CONTAINER > Starting postfix ..." + ./docker-entrypoint.sh "postfix" "-v" "start-fg" +} + +main "$@" diff --git a/providers/README.md b/providers/README.md new file mode 100644 index 0000000..4cc571b --- /dev/null +++ b/providers/README.md @@ -0,0 +1,28 @@ +# DNS Providers + +DNS providers should provide a script file named in the following format: +``` +[PROVIDER].bash +``` + +The script itself should define 4 functions: + +```bash +provider_create() { + # Call certbot to create the certificates from scratch. +} + +provider_dependencies() { + # Commands to install the provider's dependencies. +} + +provider_dkim() { + # Extract the dkim TXT record settings from "/etc/opendkim/keys/${PRIMARY_DOMAIN}/mail.txt" + # Update the "mail._domainkey.${PRIMARY_DOMAIN}" TXT record with the extracted content. +} + +provider_renew() { + # Call certbot to renew existing certificates. +} + +``` \ No newline at end of file diff --git a/providers/aws.bash b/providers/aws.bash new file mode 100644 index 0000000..19986be --- /dev/null +++ b/providers/aws.bash @@ -0,0 +1,41 @@ +#!/bin/bash + +provider_create() { + certbot certonly "${TEST_MODE}" --dns-route53 --dns-route53-propagation-seconds "${DNS_PROPAGATION_DELAY}" -d "${PRIMARY_DOMAIN}" -m "${CONTACT_EMAIL}" --agree-tos --no-eff-email +} + +provider_dependencies() { + apt install -y awscli python3-certbot-dns-route53 +} + +provider_dkim() { + + local OPERATION + local RESOURCE_RECORD + + readarray -t "DKIM_TXT_RECORD_CONTENT" < <(cut -d"(" -f2 "/etc/opendkim/keys/${PRIMARY_DOMAIN}/mail.txt" | cut -d")" -f1 | tr -d ' "\n\t' | fmt -w 255) + RESOURCE_RECORD="$(printf "\"%s\"\n" "${DKIM_TXT_RECORD_CONTENT[@]}" | jq -R . | jq -sr 'map( { "Value": . } )')" + + OPERATION="$(jq -n --arg domain "mail._domainkey.${PRIMARY_DOMAIN}" --argjson record "${RESOURCE_RECORD}" ' + { + "Comment": "Update the dkim TXT record.", + "Changes": [ + { + "Action": "UPSERT", + "ResourceRecordSet": { + "Name": $domain, + "Type": "TXT", + "TTL": 300, + "ResourceRecords": $record + } + } + ] + } + ')" + + aws route53 change-resource-record-sets --hosted-zone-id "${AWS_HOSTED_ZONE_ID}" --change-batch "${OPERATION}" +} + +provider_renew() { + certbot renew "${TEST_MODE}" --dns-route53 --dns-route53-propagation-seconds "${DNS_PROPAGATION_DELAY}" +} diff --git a/providers/cloudflare.bash b/providers/cloudflare.bash new file mode 100644 index 0000000..ab8425d --- /dev/null +++ b/providers/cloudflare.bash @@ -0,0 +1,104 @@ +#!/bin/bash + +provider_create() { + write_credential_file + certbot certonly "${TEST_MODE}" --dns-cloudflare --dns-cloudflare-credentials /tmp/cloudflare --dns-cloudflare-propagation-seconds "${DNS_PROPAGATION_DELAY}" -d "${PRIMARY_DOMAIN}" -m "${CONTACT_EMAIL}" --agree-tos --no-eff-email +} + +provider_dependencies() { + apt install -y curl python3-certbot-dns-cloudflare +} + +provider_dkim() { + + local DKIM_CONTENT + local METHOD + local PARSED_NAME + local PARSED_ID + local PAYLOAD + local RESPONSE + + dkim_create() { + local CURL_RESPONSE + + CURL_RESPONSE="$( + curl -X POST \ + --fail \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}" \ + -sL \ + "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records" + )" + echo "${CURL_RESPONSE}" + } + + dkim_get() { + local CURL_RESPONSE + + CURL_RESPONSE="$( + curl -X GET \ + --fail \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}" \ + -sL \ + "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records?type=TXT&match=all" + )" + echo "${CURL_RESPONSE}" + } + + dkim_update() { + # $1: Record ID + + local CURL_RESPONSE + + CURL_RESPONSE="$( + curl -X PUT \ + --fail \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${PAYLOAD}" \ + -sL \ + "https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/dns_records/${1}" + )" + echo "${CURL_RESPONSE}" + } + + dkim_select_method() { + METHOD="dkim_create" + while read -r LINE; do + # shellcheck disable=SC2001 + PARSED_NAME=$(sed "s/^\([^\t]*\)\t\(.*\)$/\1/" <<< "${LINE}") + # shellcheck disable=SC2001 + PARSED_ID=$(sed "s/^\([^\t]*\)\t\(.*\)$/\2/" <<< "${LINE}") + if [[ "${PARSED_NAME}" == "mail._domainkey.${PRIMARY_DOMAIN}" ]]; then + METHOD="dkim_update" + break + fi + done < <(jq -r '.result[] | .name + "\t" + .id' <<< "$(dkim_get)") + } + + DKIM_CONTENT="$(cut -d"(" -f2 "/etc/opendkim/keys/${PRIMARY_DOMAIN}/mail.txt" | cut -d")" -f1 | tr -d ' "\n\t')" + PAYLOAD=$(jq -r ".name = \"mail._domainkey.${PRIMARY_DOMAIN}\" | .content = \"${DKIM_CONTENT}\" | .type = \"TXT\"" <<< '{}') + + dkim_select_method + + RESPONSE="$(eval "${METHOD}" "${PARSED_ID}")" + + echo "${RESPONSE}" | jq + if [[ $(jq -r '.success' <<< "${RESPONSE}") != "true" ]]; then + return 1 + fi + return 0 +} + +provider_renew() { + write_credential_file + certbot renew "${TEST_MODE}" --dns_cloudflare --dns-cloudflare-credentials /tmp/cloudflare --dns-cloudflare-propagation-seconds "${DNS_PROPAGATION_DELAY}" +} + +write_credential_file() { + echo "dns_cloudflare_api_token = ${CLOUDFLARE_API_TOKEN}" >> /tmp/cloudflare + chmod 600 /tmp/cloudflare +} diff --git a/scripts/create.bash b/scripts/create.bash new file mode 100644 index 0000000..d30b9b7 --- /dev/null +++ b/scripts/create.bash @@ -0,0 +1,10 @@ +#!/bin/bash + +create() { + echo "CONTAINER > 'create' function has been called." + pushd "certbot" > /dev/null || exit 127 + echo "CONTAINER > Attempting to create certificates ..." + provider_create + install_certificates + popd > /dev/null || exit 127 +} \ No newline at end of file diff --git a/scripts/dkim.bash b/scripts/dkim.bash new file mode 100644 index 0000000..7bffe50 --- /dev/null +++ b/scripts/dkim.bash @@ -0,0 +1,8 @@ +#!/bin/bash + +function dkim() { + echo "CONTAINER > 'dkim' function has been called." + sleep "${DKIM_DELAY}" + echo "CONTAINER > Attempting to update the DNS dkim key ..." + provider_dkim +} diff --git a/scripts/install.bash b/scripts/install.bash new file mode 100644 index 0000000..111fc50 --- /dev/null +++ b/scripts/install.bash @@ -0,0 +1,8 @@ +#!/bin/bash + +function install_certificates() { + echo "CONTAINER > 'install_certificates' function has been called." + echo "CONTAINER > Attempting to install certificates ..." + cp -v /etc/letsencrypt/live/"${PRIMARY_DOMAIN}"/fullchain.pem /run/secrets/fullchain.pem + cp -v /etc/letsencrypt/live/"${PRIMARY_DOMAIN}"/privkey.pem /run/secrets/privkey.pem +} diff --git a/scripts/renew.bash b/scripts/renew.bash new file mode 100644 index 0000000..3af09a8 --- /dev/null +++ b/scripts/renew.bash @@ -0,0 +1,19 @@ +#!/bin/bash + +function renew() { + echo "CONTAINER > 'renew' function has been called." + while true; do + echo "CONTAINER > 'renew' is waiting ${RENEW_INTERVAL_IN_DAYS} days before attempting the next certificate renewal ..." + sleep $((3600*RENEW_INTERVAL_IN_DAYS)) + echo "CONTAINER > Attempting to renew certificates ..." + pushd "certbot" || exit 127 + if provider_renew; then + install_certificates + fi + popd || exit 127 + + echo "CONTAINER > Reloading dovecot and postfix ..." + dovecot reload + postfix reload + done +}