Skip to content

Commit

Permalink
feat(PROTOTYPE): complete initial implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
niall-byrne committed Nov 16, 2023
0 parents commit c8ff356
Show file tree
Hide file tree
Showing 12 changed files with 373 additions and 0 deletions.
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test
34 changes: 34 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 |
54 changes: 54 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
28 changes: 28 additions & 0 deletions providers/README.md
Original file line number Diff line number Diff line change
@@ -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.
}

```
41 changes: 41 additions & 0 deletions providers/aws.bash
Original file line number Diff line number Diff line change
@@ -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}"
}
104 changes: 104 additions & 0 deletions providers/cloudflare.bash
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions scripts/create.bash
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions scripts/dkim.bash
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions scripts/install.bash
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions scripts/renew.bash
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit c8ff356

Please sign in to comment.