Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
bdf50c0
bringing in dehydrated to manage acme cert request/renews
jasonpincin Sep 30, 2016
9596d9b
config and hooks for dehydrated, hooks will propogate challenge/cert …
jasonpincin Sep 30, 2016
974ddbd
cannot generate dynamic filenames from consul-template, so hacking it
jasonpincin Sep 30, 2016
9b4d272
respond to acme challenges if acme is enabled
jasonpincin Sep 30, 2016
7b1cc78
Respect CONSUL_AGENT and CONSUL env vars, simplify kv structure (1 do…
jasonpincin Sep 30, 2016
1b01db8
adding ssl cert and acme challenge token templates
jasonpincin Sep 30, 2016
e73717e
corrected paths, removed consul host from config file to be specified…
jasonpincin Sep 30, 2016
ddf9fc5
moved acme watch file
jasonpincin Sep 30, 2016
a59c152
accept source and destination as args, remove source when done
jasonpincin Sep 30, 2016
6def330
Insure directories needed for acme/ssl operation are present
jasonpincin Sep 30, 2016
9aa587a
renamed env var to ACME_DOMAIN, supporting single domain for now
jasonpincin Sep 30, 2016
f113253
do not need trailing /
jasonpincin Sep 30, 2016
a67a567
Added script for managing Consul sessions (for use with leader lock)
jasonpincin Sep 30, 2016
ae56880
Added script for managing leader aquisition (determines container res…
jasonpincin Sep 30, 2016
4d67190
Added tasks for renewing consol session, claiming leader, renewing/cl…
jasonpincin Sep 30, 2016
893e1c9
Support toggling ACME/LetsEncrypt between staging/production endpoint…
jasonpincin Sep 30, 2016
35cedcd
fetch dehydrated at docker build time
jasonpincin Sep 30, 2016
3039fc3
If ACME_ENV not set, default to staging.
jasonpincin Oct 3, 2016
562ee7e
consolidated acme related tasks into bin/acme. Moved etc/dehydrated t…
jasonpincin Oct 4, 2016
c5e3485
Corrected template formatting errors
jasonpincin Oct 4, 2016
42f92ef
Fixed typo. Added jq.
jasonpincin Oct 4, 2016
9f7e18a
Corrected commands to add jq
jasonpincin Oct 4, 2016
b65eafc
Verify challenge token is written to all nginx containers before acce…
jasonpincin Oct 4, 2016
b48b811
No limit to consul-template restarts. Formatting fixes.
jasonpincin Oct 4, 2016
f8f384a
Defined dehydrated WELLKNOWN
jasonpincin Oct 4, 2016
7122b63
Moved ENV VAR lookups into acme script to declutter containerpilot.js…
jasonpincin Oct 4, 2016
079f977
Upgraded consul to 0.7.0. Adjusted options.
jasonpincin Oct 4, 2016
56a39e1
Insure all nginx containers have challenge token before accepting cha…
jasonpincin Oct 4, 2016
f1dfdfb
check that key exists before writing it
jasonpincin Oct 4, 2016
99141c4
Add SSL interface. Increasedd acme-checkin frequency from 2.5 mins to 5,
jasonpincin Oct 4, 2016
0b2a71e
Handle dehydrated flags in acme script
jasonpincin Oct 4, 2016
04feeb3
Add SSL directives to Nginx config if ACME_DOMAIN env var set
jasonpincin Oct 4, 2016
6aac101
Remove debugging messages
jasonpincin Oct 4, 2016
b51807e
Upgrade consul agent to 0.7.0
jasonpincin Oct 4, 2016
55b893f
Seperate template for ssl enabled nginx for startup reasons
jasonpincin Oct 6, 2016
c1bf4ab
Reload will choose whether to use ssl template or non-ssl template ba…
jasonpincin Oct 6, 2016
0ac4ba6
Perform reload as part of consul-template key writes instead of acme …
jasonpincin Oct 6, 2016
0d2b2c8
Expose port 443. Correct mem_limit.
jasonpincin Oct 6, 2016
0019d51
Mention ACME support in README
jasonpincin Oct 6, 2016
3416757
Clean up logs my omitting sucesful (200) health check requests from n…
jasonpincin Oct 6, 2016
6025b0f
Updated example backend to Consul v0.7.0
jasonpincin Oct 6, 2016
7709242
Un-handwrapped text
jasonpincin Oct 7, 2016
5b1c69a
Link to document on using your own domain with Triton CNS
jasonpincin Oct 7, 2016
cdfaf0f
Improve readability of code by using pipefail
jasonpincin Oct 7, 2016
8febef6
Redirect http to https (except for health check) when SSL is enabled
jasonpincin Oct 7, 2016
f4f21b2
Include host header in ssl health check
jasonpincin Oct 7, 2016
894f7e7
Write certs/keys to temporary location before copying them to final l…
jasonpincin Oct 7, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ RUN apt-get update \

# Install Consul
# Releases at https://releases.hashicorp.com/consul
RUN export CONSUL_VERSION=0.6.4 \
&& export CONSUL_CHECKSUM=abdf0e1856292468e2c9971420d73b805e93888e006c76324ae39416edcf0627 \
RUN export CONSUL_VERSION=0.7.0 \
&& export CONSUL_CHECKSUM=b350591af10d7d23514ebaa0565638539900cdb3aaa048f077217c4c46653dd8 \
&& curl --retry 7 --fail -vo /tmp/consul.zip "https://releases.hashicorp.com/consul/${CONSUL_VERSION}/consul_${CONSUL_VERSION}_linux_amd64.zip" \
&& echo "${CONSUL_CHECKSUM} /tmp/consul.zip" | sha256sum -c \
&& unzip /tmp/consul -d /usr/local/bin \
Expand Down Expand Up @@ -43,10 +43,31 @@ RUN export CONTAINERPILOT_CHECKSUM=ec9dbedaca9f4a7a50762f50768cbc42879c7208 \
&& tar zxf /tmp/containerpilot.tar.gz -C /usr/local/bin \
&& rm /tmp/containerpilot.tar.gz

# Add Dehydrated
RUN export DEHYDRATED_VERSION=v0.3.1 \
&& curl --retry 8 --fail -Lso /tmp/dehydrated.tar.gz "https://github.com/lukas2511/dehydrated/archive/${DEHYDRATED_VERSION}.tar.gz" \
&& tar xzf /tmp/dehydrated.tar.gz -C /tmp \
&& mv /tmp/dehydrated-0.3.1/dehydrated /usr/local/bin \
&& rm -rf /tmp/dehydrated-0.3.1

# Add jq
RUN export JQ_VERSION=1.5 \
&& curl --retry 8 --fail -Lso /usr/local/bin/jq "https://github.com/stedolan/jq/releases/download/jq-${JQ_VERSION}/jq-linux64" \
&& chmod a+x /usr/local/bin/jq

# Add our configuration files and scripts
COPY etc /etc
COPY bin /usr/local/bin

# Usable SSL certs written here
RUN mkdir -p /var/www/ssl
# Temporary/work space for keys
RUN mkdir -p /var/www/acme/ssl
# ACME challenge tokens written here
RUN mkdir -p /var/www/acme/challenge
# Consul session data written here
RUN mkdir -p /var/consul

CMD [ "/usr/local/bin/containerpilot", \
"nginx", \
"-g", \
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Autopilot Pattern Nginx
==========
=======================

*A re-usable Nginx base image implemented according to the [Autopilot Pattern](http://autopilotpattern.io/) for automatic discovery and configuration.*

Expand Down Expand Up @@ -39,3 +39,32 @@ You can open the demo app that Nginx is proxying by opening a browser to the Ngi
```bash
open "http://$(triton ip nginx_nginx_1)/example"
```

### Configuring LetsEncrypt (ACME)

Setting the `ACME_DOMAIN` environment variable will enable LetsEncrypt within the image. The image will automatically acquire certificates for the given domain, and renew them over time. If you scale to multiple instances of Nginx, they will elect a leader who will be responsible for renewing the certificates. Any challenge response tokens as well as acquired certificates will be replicated to all Nginx instances.

By default, this process will use the LetsEncrypt staging endpoint, so as not to impact your api limits. When ready for production, you must also set the `ACME_ENV` environment variable to `production`.

You must ensure the domain resolves to your Nginx containers so that they can respond to the ACME http challenges. Triton users may [refer to this document](https://docs.joyent.com/public-cloud/network/cns/faq#can-i-use-my-own-domain-name-with-triton-cns) for more information on how to insure your domain resolves to your Triton containers.

Example excerpt from `docker-compose.yml` with LetsEncrypt enabled:

```yaml
nginx:
image: autopilotpattern/nginx
restart: always
mem_limit: 512m
env_file: _env
environment:
- BACKEND=example
- CONSUL_AGENT=1
- ACME_ENV=staging
- ACME_DOMAIN=example.com
ports:
- 80
- 443
- 9090
labels:
- triton.cns.services=nginx
```
164 changes: 164 additions & 0 deletions bin/acme
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env bash

pushd `dirname $0` > /dev/null
SCRIPTPATH=`pwd -P`
popd > /dev/null

CONSUL_HOST_DEFAULT="localhost"
if [ "${CONSUL_AGENT}" = "" -a "${CONSUL}" != "" ]; then
CONSUL_HOST_DEFAULT=${CONSUL}
fi
CONSUL_HOST=${CONSUL_HOST:-$CONSUL_HOST_DEFAULT}
CONSUL_ROOT="http://${CONSUL_HOST}:8500/v1"
CONSUL_KEY_ROOT="${CONSUL_ROOT}/kv/nginx"

SESSION_DIR_DEFAULT="/var/consul"
SESSION_DIR=${SESSION_DIR:-$SESSION_DIR_DEFAULT}
SESSION_FILE=${SESSION_DIR}/session

TEMP_CERT_DIR="/var/www/acme/ssl"
CERT_DIR="/var/www/ssl"

ACME_ENV=${ACME_ENV:-staging}

function getConsulSession () {
if [ -f $SESSION_FILE ]; then
SID=$(cat ${SESSION_DIR}/session)
local STATUS=$(curl -s ${CONSUL_ROOT}/session/info/${SID})
if [ "${STATUS}" != "[]" ]; then
echo $SID
return 0
else
return 1
fi
else
return 1
fi
}

function renewConsulSession () {
local SID="$(getConsulSession)"
rc=$?
if [ $rc -ne 0 ]; then
createConsulSession
return $?
else
printf "Renewing Consul session ${SID}... "
local STATUS=$(curl -s -o /dev/null -X PUT -w '%{http_code}' ${CONSUL_ROOT}/session/renew/${SID})
if [ "${STATUS}" = "200" ]; then
echo "complete"
return 0
else
echo "failed"
createConsulSession
return $?
fi
fi
}

function createConsulSession () {
printf "Creating Consul session... "
local SID=$(curl -sX PUT -d '{"LockDelay":"0s","Name":"acme-lock","Behavior":"release","TTL":"600s"}' ${CONSUL_ROOT}/session/create | awk -F '"' '{print $4}')
rc=$?
if [[ $rc -ne 0 ]]; then
echo "failed"
return 1
else
echo $SID
echo $SID > $SESSION_FILE
return 0
fi
}

function acquireLeader () {
local SID="$(getConsulSession)"
STATUS=$(curl -sX PUT -d "$(hostname)" "${CONSUL_KEY_ROOT}/acme/leader?acquire=${SID}")

if [ "${STATUS}" = "true" ]; then
echo "ACME leader claimed"
return 0
else
echo "Failed to claim ACME leader"
return 1
fi
}

function generateChallengeToken () {
IN=$1
OUT=$2
FILENAME="$(sed '1q;d' ${IN})"
VALUE="$(sed '2q;d' ${IN})"
LAST_FILENAME="$(sed '3q;d' ${IN})"

if [ "${FILENAME}" ]; then
echo "${VALUE}" > ${OUT}/${FILENAME}
fi

if [ "${LAST_FILENAME}" ]; then
rm -f ${OUT}/${LAST_FILENAME}
fi

rm ${IN}
}

function updateKeys () {
local TEMP_FULLCHAIN="${TEMP_CERT_DIR}/fullchain.pem"
local TEMP_PRIVKEY="${TEMP_CERT_DIR}/privkey.pem"
local FULLCHAIN="${CERT_DIR}/fullchain.pem"
local PRIVKEY="${CERT_DIR}/privkey.pem"
if [ -f ${TEMP_FULLCHAIN} -a -f ${TEMP_PRIVKEY} -a "$(cat ${TEMP_FULLCHAIN})" != "" -a "$(cat ${TEMP_PRIVKEY})" != "" ]; then
cp -f $TEMP_FULLCHAIN $FULLCHAIN
cp -f $TEMP_PRIVKEY $PRIVKEY
$SCRIPTPATH/reload.sh
fi
}

case "$1" in
get-consul-session)
getConsulSession
;;
create-consul-session)
createConsulSession
;;
renew-consul-session)
renewConsulSession
;;
acquire-leader)
acquireLeader
;;
checkin)
renewConsulSession &&
( acquireLeader || exit 0 )
;;
init)
if [ -f ${CERT_DIR}/fullchain.pem -a -f ${CERT_DIR}/privkey.pem ]; then
exit 0
fi
shift
renewConsulSession &&
acquireLeader &&
${SCRIPTPATH}/dehydrated --cron --domain ${ACME_DOMAIN} --hook /etc/acme/dehydrated/hook.sh --config /etc/acme/dehydrated/config.${ACME_ENV}
;;
renew-certs)
shift
renewConsulSession &&
acquireLeader &&
${SCRIPTPATH}/dehydrated --cron --domain ${ACME_DOMAIN} --hook /etc/acme/dehydrated/hook.sh --config /etc/acme/dehydrated/config.${ACME_ENV}
;;
clean-certs)
shift
renewConsulSession &&
acquireLeader &&
${SCRIPTPATH}/dehydrated --cleanup --domain ${ACME_DOMAIN} --hook /etc/acme/dehydrated/hook.sh --config /etc/acme/dehydrated/config.${ACME_ENV}
;;
generate-challenge-token)
generateChallengeToken $2 $3
;;
update-keys)
updateKeys
;;
*)
echo $"Usage: $0 [ {get,create,renew}-consul-session | acquire-leader | init | checkin | renew-certs | clean-certs | generate-challenge-token ]"
exit 1
;;
esac
7 changes: 6 additions & 1 deletion bin/reload.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

SERVICE_NAME=${SERVICE_NAME:-nginx}
CONSUL=${CONSUL:-consul}
CERT_DIR="/var/www/ssl"

# Render Nginx configuration template using values from Consul,
# but do not reload because Nginx has't started yet
Expand All @@ -16,11 +17,15 @@ preStart() {
# Render Nginx configuration template using values from Consul,
# then gracefully reload Nginx
onChange() {
local TEMPLATE="nginx.conf.ctmpl"
if [ -f ${CERT_DIR}/fullchain.pem -a -f ${CERT_DIR}/privkey.pem ]; then
TEMPLATE="nginx-ssl.conf.ctmpl"
fi
consul-template \
-once \
-dedup \
-consul ${CONSUL}:8500 \
-template "/etc/nginx/nginx.conf.ctmpl:/etc/nginx/nginx.conf:nginx -s reload"
-template "/etc/nginx/${TEMPLATE}:/etc/nginx/nginx.conf:nginx -s reload"
}

help() {
Expand Down
6 changes: 4 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ nginx:
environment:
- BACKEND=example
- CONSUL_AGENT=1
- ACME_ENV=staging
ports:
- 80
- 443
- 9090 # so we can see telemetry
labels:
- triton.cns.services=nginx
Expand All @@ -32,7 +34,7 @@ example:
# Start with a single host which will bootstrap the cluster.
# In production we'll want to use an HA cluster.
consul:
image: progrium/consul:latest
image: consul:v0.7.0
restart: always
mem_limit: 128m
expose:
Expand All @@ -48,4 +50,4 @@ consul:
- 127.0.0.1
labels:
- triton.cns.services=consul
command: -server -bootstrap -ui-dir /ui
command: agent -server -client=0.0.0.0 -bootstrap -ui
2 changes: 2 additions & 0 deletions etc/acme/dehydrated/config.production
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CA="https://acme-v01.api.letsencrypt.org/directory"
WELLKNOWN="/var/www/acme/challenge"
2 changes: 2 additions & 0 deletions etc/acme/dehydrated/config.staging
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CA="https://acme-staging.api.letsencrypt.org/directory"
WELLKNOWN="/var/www/acme/challenge"
82 changes: 82 additions & 0 deletions etc/acme/dehydrated/hook.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -o pipefail

CONSUL_HOST_DEFAULT="localhost"
if [ "${CONSUL_AGENT}" = "" -a "${CONSUL}" != "" ]; then
CONSUL_HOST_DEFAULT=${CONSUL}
fi
CONSUL_HOST=${CONSUL_HOST:-$CONSUL_HOST_DEFAULT}
CONSUL_ROOT="http://${CONSUL_HOST}:8500/v1"
CONSUL_KEY_ROOT="${CONSUL_ROOT}/kv/nginx"
CHALLENGE_PATH="/.well-known/acme-challenge"

function deploy_challenge {
local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"
local TOKEN_DEPLOY_RETRY_LIMIT=6

(curl -sX PUT -d "${TOKEN_FILENAME}" ${CONSUL_KEY_ROOT}/acme/challenge/token-filename | log \
&& curl -sX PUT -d "${TOKEN_VALUE}" ${CONSUL_KEY_ROOT}/acme/challenge/token-value | log \
) || (log "${FUNCNAME} failed"; return 1)

# verify all nginx containers are responding with challenge before continuing
local NGINX_INSTANCES=$(curl -s "${CONSUL_ROOT}/catalog/service/nginx" | jq -Mr '.[].ServiceAddress')
local NGINX_INSTANCE_COUNT=$(echo "${NGINX_INSTANCES}" | wc -l)
local RETRIES=0
local MATCHING=0
printf " + Waiting for challenge to be deployed..."
while [ $RETRIES -lt $TOKEN_DEPLOY_RETRY_LIMIT -a $MATCHING -lt $NGINX_INSTANCE_COUNT ]; do
MATCHING=0
for NGINX_INSTANCE_HOST in $NGINX_INSTANCES; do
if [ "$(curl -s --header \"HOST: ${DOMAIN}\" http://${NGINX_INSTANCE_HOST}${CHALLENGE_PATH}/${TOKEN_FILENAME})" = "${TOKEN_VALUE}" ]; then
MATCHING=$((MATCHING+1))
fi
done
RETRIES=$((RETRIES+1))
printf "."
sleep 2
done
echo
}

function clean_challenge {
local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

curl -sX DELETE ${CONSUL_KEY_ROOT}/acme/challenge/token-filename | log \
&& curl -sX DELETE ${CONSUL_KEY_ROOT}/acme/challenge/token-value | log \
&& curl -sX PUT -d "${TOKEN_FILENAME}" ${CONSUL_KEY_ROOT}/acme/challenge/last-token-filename | log \
&& return 0
log "${FUNCNAME} failed"
return 1
}

function deploy_cert {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"

curl -sX PUT -d "$(cat ${KEYFILE})" ${CONSUL_KEY_ROOT}/acme/key | log \
&& curl -sX PUT -d "$(cat ${CERTFILE})" ${CONSUL_KEY_ROOT}/acme/cert | log \
&& curl -sX PUT -d "$(cat ${CHAINFILE})" ${CONSUL_KEY_ROOT}/acme/chain | log \
&& curl -sX PUT -d "$(cat ${FULLCHAINFILE})" ${CONSUL_KEY_ROOT}/acme/fullchain | log \
&& curl -sX PUT -d "${TIMESTAMP}" ${CONSUL_KEY_ROOT}/acme/timestamp | log \
&& curl -sX PUT -d "$(date +%s)" ${CONSUL_KEY_ROOT}/acme/touched | log \
&& return 0
log "${FUNCNAME} failed"
return 1
}

function unchanged_cert {
local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"
}

function log {
if [ -n "$1" ]; then
IN="$1"
else
read IN
fi

if [ "${IN}" != "true" ]; then
echo " - ${IN}"
fi
}

HANDLER=$1; shift; $HANDLER $@
1 change: 1 addition & 0 deletions etc/acme/templates/cert.ctmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{if key "nginx/acme/cert"}}{{key "nginx/acme/cert"}}{{end}}
1 change: 1 addition & 0 deletions etc/acme/templates/chain.ctmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{if key "nginx/acme/chain"}}{{key "nginx/acme/chain"}}{{end}}
3 changes: 3 additions & 0 deletions etc/acme/templates/challenge-token.ctmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{{if key "nginx/acme/challenge/token-filename"}}{{key "nginx/acme/challenge/token-filename"}}{{end}}
{{if key "nginx/acme/challenge/token-value"}}{{key "nginx/acme/challenge/token-value"}}{{end}}
{{if key "nginx/acme/challenge/last-token-filename"}}{{key "nginx/acme/challenge/last-token-filename"}}{{end}}
1 change: 1 addition & 0 deletions etc/acme/templates/fullchain.ctmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{if key "nginx/acme/fullchain"}}{{key "nginx/acme/fullchain"}}{{end}}
1 change: 1 addition & 0 deletions etc/acme/templates/privkey.ctmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{if key "nginx/acme/key"}}{{key "nginx/acme/key"}}{{end}}
Loading