├── test ├── Dockerfile ├── letsencrypt-dcos-test-2.json ├── letsencrypt-dcos-test-1.json └── nginx.conf ├── Dockerfile ├── letsencrypt-dcos.json ├── run.sh ├── post_cert.py └── README.md /test/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:jessie 2 | 3 | RUN apt-get update && apt-get -y install curl wget build-essential libreadline-dev libncurses5-dev libpcre3-dev libssl-dev && apt-get -q -y clean 4 | RUN wget http://openresty.org/download/openresty-1.9.7.4.tar.gz \ 5 | && tar xvfz openresty-1.9.7.4.tar.gz \ 6 | && cd openresty-* \ 7 | && ./configure --with-luajit --with-http_gzip_static_module --with-http_ssl_module \ 8 | && make \ 9 | && make install \ 10 | && rm -rf /openresty* 11 | 12 | EXPOSE 8080 13 | CMD /usr/local/openresty/nginx/sbin/nginx 14 | 15 | ADD nginx.conf /usr/local/openresty/nginx/conf/nginx.conf 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:jessie 2 | 3 | WORKDIR / 4 | ENV DEBIAN_FRONTEND=noninteractive 5 | ENV CERTBOT_VERSION=0.26.1 6 | RUN apt-get update \ 7 | && apt-get install -y unzip curl python-pip \ 8 | && pip install --upgrade pip \ 9 | && pip install virtualenv --upgrade \ 10 | && curl -Ls -o /certbot.zip https://github.com/certbot/certbot/archive/v${CERTBOT_VERSION}.zip \ 11 | && unzip certbot.zip \ 12 | && mv certbot-${CERTBOT_VERSION} certbot \ 13 | && cd certbot \ 14 | && ./certbot-auto --os-packages-only --noninteractive \ 15 | && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 16 | 17 | EXPOSE 80 18 | 19 | WORKDIR /certbot 20 | COPY run.sh /certbot/run.sh 21 | COPY post_cert.py /certbot/post_cert.py 22 | 23 | ENTRYPOINT ["/certbot/run.sh"] 24 | -------------------------------------------------------------------------------- /test/letsencrypt-dcos-test-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/letsencrypt-dcos-test-2", 3 | "cpus": 0.5, 4 | "mem": 128, 5 | "instances": 2, 6 | "container": { 7 | "type": "DOCKER", 8 | "docker": { 9 | "image": "mesosphere/letsencrypt-dcos-test", 10 | "network": "BRIDGE", 11 | "portMappings": [{ 12 | "containerPort": 8080, 13 | "hostPort": 0, 14 | "servicePort": 10002 15 | }] 16 | } 17 | }, 18 | "healthChecks": [{ 19 | "protocol": "HTTP", 20 | "path": "/health", 21 | "gracePeriodSeconds": 60, 22 | "intervalSeconds": 10, 23 | "portIndex": 0, 24 | "timeoutSeconds": 10, 25 | "maxConsecutiveFailures": 2 26 | }], 27 | "labels":{ 28 | "HAPROXY_GROUP":"external", 29 | "HAPROXY_0_VHOST":"ssl-test-2.mesosphere.com" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/letsencrypt-dcos-test-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/letsencrypt-dcos-test-1", 3 | "cpus": 0.5, 4 | "mem": 128, 5 | "instances": 2, 6 | "container": { 7 | "type": "DOCKER", 8 | "docker": { 9 | "image": "mesosphere/letsencrypt-dcos-test", 10 | "network": "BRIDGE", 11 | "portMappings": [{ 12 | "containerPort": 8080, 13 | "hostPort": 0, 14 | "servicePort": 10001 15 | }] 16 | } 17 | }, 18 | "healthChecks": [{ 19 | "protocol": "HTTP", 20 | "path": "/health", 21 | "gracePeriodSeconds": 60, 22 | "intervalSeconds": 10, 23 | "portIndex": 0, 24 | "timeoutSeconds": 10, 25 | "maxConsecutiveFailures": 2 26 | }], 27 | "labels":{ 28 | "HAPROXY_GROUP":"external", 29 | "HAPROXY_0_VHOST":"ssl-test-1.mesosphere.com", 30 | "HAPROXY_0_REDIRECT_TO_HTTPS":"true", 31 | "HAPROXY_0_USE_HSTS":"true" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/nginx.conf: -------------------------------------------------------------------------------- 1 | daemon off; 2 | error_log stderr debug; 3 | worker_processes 3; 4 | 5 | events { 6 | worker_connections 1024; 7 | } 8 | 9 | http { 10 | access_log off; 11 | include mime.types; 12 | limit_req_zone $binary_remote_addr zone=any:10m rate=1r/s; 13 | 14 | server { 15 | listen 8080; 16 | 17 | location /health { 18 | return 200; 19 | } 20 | 21 | location / { 22 | default_type 'text/plain'; 23 | limit_req zone=any burst=5; 24 | 25 | content_by_lua_block { 26 | if ngx.req.get_method() == "GET" then 27 | local headers = ngx.req.get_headers() 28 | local host = headers["Host"] 29 | local proto = headers["X-Forwarded-Proto"] 30 | if proto == nil then 31 | proto = "http" 32 | end 33 | local cmd = "curl -sLIv " .. proto .. "://" .. host .. " 2>&1 | cat" 34 | local f = assert(io.popen(cmd, 'r')) 35 | local s = assert(f:read('*a')) 36 | f:close() 37 | ngx.say("command: " .. cmd .. "\n\n" .. s) 38 | end 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /letsencrypt-dcos.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/letsencrypt-dcos", 3 | "cpus": 0.05, 4 | "mem": 512, 5 | "instances": 1, 6 | "container": { 7 | "type": "DOCKER", 8 | "volumes": [ 9 | { 10 | "containerPath": "/etc/letsencrypt", 11 | "hostPath": "data", 12 | "mode": "RW" 13 | }, 14 | { 15 | "containerPath": "data", 16 | "mode": "RW", 17 | "persistent": { 18 | "size": 500 19 | } 20 | } 21 | ], 22 | "docker": { 23 | "image": "mesosphere/letsencrypt-dcos:v1.0.4", 24 | "network": "BRIDGE", 25 | "portMappings": [ 26 | { 27 | "containerPort": 80, 28 | "servicePort": 10000, 29 | "protocol": "tcp" 30 | } 31 | ] 32 | } 33 | }, 34 | "env": { 35 | "MARATHON_LB_ID": "marathon-lb", 36 | "MARATHON_URL": "http://marathon.mesos:8080", 37 | "LETSENCRYPT_EMAIL": "brenden@mesosphere.com" 38 | }, 39 | "labels": { 40 | "HAPROXY_0_VHOST": "ssl-test-1.mesosphere.com,ssl-test-2.mesosphere.com", 41 | "HAPROXY_GROUP": "external", 42 | "HAPROXY_0_PATH": "/.well-known/acme-challenge" 43 | }, 44 | "backoffSeconds": 5, 45 | "upgradeStrategy": { 46 | "minimumHealthCapacity": 0.5, 47 | "maximumOverCapacity": 0 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Wait to settle 5 | sleep 15 6 | 7 | # Get our SSL domains from the Marathon app label 8 | SSL_DOMAINS=$(curl -s ${MARATHON_URL}/v2/apps${MARATHON_APP_ID} | python -c 'import sys, json; print(json.load(sys.stdin)["app"]["labels"]["HAPROXY_0_VHOST"])') 9 | 10 | 11 | IFS=',' read -ra ADDR <<< "$SSL_DOMAINS" 12 | DOMAIN_ARGS="" 13 | DOMAIN_FIRST="" 14 | for i in "${ADDR[@]}"; do 15 | if [ -z $DOMAIN_FIRST ]; then 16 | DOMAIN_FIRST=$i 17 | fi 18 | DOMAIN_ARGS="$DOMAIN_ARGS -d $i" 19 | done 20 | 21 | 22 | echo "DOMAIN_ARGS: ${DOMAIN_ARGS}" 23 | echo "DOMAIN_FIRST: ${DOMAIN_FIRST}" 24 | 25 | echo "Running certbot-auto to generate initial signed cert" 26 | ./certbot-auto --no-self-upgrade certonly --standalone \ 27 | --preferred-challenges http-01 $DOMAIN_ARGS \ 28 | --email $LETSENCRYPT_EMAIL --agree-tos --noninteractive --no-redirect \ 29 | --rsa-key-size 4096 --expand 30 | 31 | while [ true ]; do 32 | cat /etc/letsencrypt/live/$DOMAIN_FIRST/fullchain.pem \ 33 | /etc/letsencrypt/live/$DOMAIN_FIRST/privkey.pem > \ 34 | /etc/letsencrypt/live/$DOMAIN_FIRST.pem 35 | 36 | echo "Posting new cert to marathon-lb" 37 | ./post_cert.py /etc/letsencrypt/live/$DOMAIN_FIRST.pem 38 | 39 | sleep 24h 40 | 41 | echo "About to attempt renewal" 42 | ./certbot-auto --no-self-upgrade renew 43 | done 44 | -------------------------------------------------------------------------------- /post_cert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import json 6 | import requests 7 | import time 8 | 9 | url = os.environ.get('MARATHON_URL') 10 | marathon_lb_id = os.environ.get('MARATHON_LB_ID') 11 | marathon_lb_cert_env = \ 12 | os.environ.get('MARATHON_LB_CERT_ENV', 'HAPROXY_SSL_CERT') 13 | 14 | print("Retrieving current marathon-lb cert") 15 | sys.stdout.flush() 16 | r = requests.get(url + '/v2/apps/' + marathon_lb_id) 17 | mlb = r.json() 18 | env = mlb['app']['env'] 19 | cert = '' 20 | 21 | with open(sys.argv[1], 'r') as f: 22 | cert = f.read() 23 | 24 | print("Comparing old cert to new cert") 25 | sys.stdout.flush() 26 | if cert != env.get(marathon_lb_cert_env, ''): 27 | env[marathon_lb_cert_env] = cert 28 | 29 | print("Deploying marathon-lb with new cert") 30 | sys.stdout.flush() 31 | headers = {'Content-Type': 'application/json'} 32 | r = requests.put(url + '/v2/apps/' + marathon_lb_id, 33 | headers=headers, 34 | data=json.dumps({ 35 | 'id': marathon_lb_id, 36 | 'env': env 37 | }, encoding='utf-8')) 38 | deploymentId = r.json()['deploymentId'] 39 | 40 | # Wait for deployment to complete 41 | deployment_exists = True 42 | while deployment_exists: 43 | time.sleep(5) 44 | print("Waiting for deployment to complete") 45 | sys.stdout.flush() 46 | r = requests.get(url + '/v2/deployments') 47 | deployments = r.json() 48 | deployment_exists = False 49 | for deployment in deployments: 50 | if deployment['id'] == deploymentId: 51 | deployment_exists = True 52 | break 53 | print("Deployment complete") 54 | sys.stdout.flush() 55 | else: 56 | print("Cert did not change") 57 | sys.stdout.flush() 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Let's Encrypt DC/OS! 2 | 3 | 4 | # This repository is now deprecated, and this project has moved to https://github.com/dcos-labs/letsencrypt-dcos 5 | 6 | This is a sample [Marathon](https://github.com/mesosphere/marathon) app for encrypting your [Marathon-lb](https://github.com/mesosphere/marathon-lb) HAProxy endpoints using [Let's Encrypt](https://letsencrypt.org/). With this, you can automatically generate and renew valid SSL certs with Marathon-lb. 7 | 8 | ## Getting started 9 | 10 | Clone (or manually copy) this repo, and modify the [letsencrypt-dcos.json](letsencrypt-dcos.json) file to include: 11 | - The list of hostnames (must be FQDNs) for which you want to generate SSL certs (in `HAPROXY_0_VHOST`) 12 | - An admin email address for your certificate (in `LETSENCRYPT_EMAIL`) 13 | - The Marathon API endpoint (in `MARATHON_URL`) 14 | - The Marathon-lb app ID (in `MARATHON_LB_ID`) 15 | - Ensure you have **at least 2 or more** public agents in your DC/OS cluster, and that marathon-lb is scaled out to more than 1 public agent. Deploying this app requires this since it entails restarting marathon-lb. 16 | 17 | Now launch the `letsencrypt-dcos` Marathon app: 18 | 19 | ``` 20 | $ dcos marathon app add letsencrypt-dcos.json 21 | ``` 22 | 23 | There are 2 test apps included, based on [openresty](https://openresty.org/), which you can use to test everything. Have a look in the `test/` directory within the repo. 24 | 25 | ## How does it work? 26 | 27 | The app includes 2 scripts: [`run.sh`](run.sh) and [`post_cert.py`](post_cert.py). The first script (`run.sh`) will generate the initial SSL cert and POST the cert to Marathon for Marathon-lb. It will then attempt to renew & update the cert every 24 hours. The `post_cert.py` script will compare the current cert in Marathon to the current live cert, and update it as necessary. `post_cert.py` is called after the initial cert is generated, and again every 24 hours after a renewal attempt. 28 | 29 | A persistent volume called `data` is mounted inside the container at `/etc/letsencrypt` which contains the certificates and other generated state. 30 | 31 | ## Limitations 32 | 33 | - You may only have up to 100 domains per cert. 34 | - Let's Encrypt currently has rate limits, such as issuing a maximum of 5 certs per set of domains per week. 35 | - Currently, when the cert is updated, it requires a full redeploy of Marathon-lb. This means there may be a few seconds of downtime as the deployment occurs. This can be mitigated by placing another LB (such as an ELB or F5) in front of HAProxy. 36 | --------------------------------------------------------------------------------