├── Dockerfile ├── README.md ├── check_new_certs.sh ├── fetch_certs.sh ├── nginx ├── letsencrypt.conf └── nginx.conf ├── recreate_pods.sh ├── refresh_certs.sh ├── save_certs.sh └── start.sh /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | 3 | ENV KUBECTL_VERSION v1.3.0 4 | 5 | RUN apt-get update && apt-get install -y git wget cron bc 6 | 7 | RUN mkdir -p /letsencrypt/challenges/.well-known/acme-challenge 8 | RUN git clone https://github.com/certbot/certbot /letsencrypt/app 9 | WORKDIR /letsencrypt/app 10 | RUN ./letsencrypt-auto; exit 0 11 | 12 | # You should see "OK" if you go to http:///.well-known/acme-challenge/health 13 | 14 | RUN echo "OK" > /letsencrypt/challenges/.well-known/acme-challenge/health 15 | 16 | # Install kubectl 17 | RUN wget https://storage.googleapis.com/kubernetes-release/release/$KUBECTL_VERSION/bin/linux/amd64/kubectl 18 | RUN chmod +x kubectl 19 | RUN mv kubectl /usr/local/bin/ 20 | 21 | # Add our nginx config for routing through to the challenge results 22 | RUN rm /etc/nginx/conf.d/*.conf 23 | ADD nginx/nginx.conf /etc/nginx/ 24 | ADD nginx/letsencrypt.conf /etc/nginx/conf.d/ 25 | 26 | # Add some helper scripts for getting and saving scripts later 27 | ADD fetch_certs.sh /letsencrypt/ 28 | ADD save_certs.sh /letsencrypt/ 29 | ADD recreate_pods.sh /letsencrypt/ 30 | ADD refresh_certs.sh /letsencrypt/ 31 | ADD start.sh /letsencrypt/ 32 | 33 | ADD nginx/letsencrypt.conf /etc/nginx/snippets/letsencrypt.conf 34 | 35 | RUN ln -s /root/.local/share/letsencrypt/bin/letsencrypt /usr/local/bin/letsencrypt 36 | 37 | WORKDIR /letsencrypt 38 | 39 | ENTRYPOINT ./start.sh 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kubernetes-ssl-manager 2 | 3 | # Problem 4 | When deploying services to Kubernetes, a certificate has to be injected into the container via secret. It doesn't make sense to have each container renew it's own certificates as it's state can be wiped at any given time. 5 | 6 | # Solution 7 | Build a service within each Kubernetes namespace that handles renewing all certificates used in that namespace. This service would kick off the request to renew each cert at a predetermined interval. It would then accept all verification requests ( GET request to domain/.well-known/acme-challenge ) and respond as necessary. After being issued the new certificate, it would recreate the appropriate secret which contains that certificate and initiate a restart of any container or service necessary. 8 | 9 | Available on docker hub as [phutchins/kubernetes-ssl-manager](https://hub.docker.com/r/phutchins/kubernetes-ssl-manager) 10 | 11 | ## Useful commands 12 | 13 | ### Generate a new set of certs 14 | 15 | Once this container is running you can generate new certificates using: 16 | 17 | ``` 18 | kubectl exec -it -- bash -c 'EMAIL=fred@fred.com DOMAINS=example.com foo.example.com ./fetch_certs.sh' 19 | ``` 20 | 21 | ### Save the set of certificates as a secret 22 | 23 | ``` 24 | kubectl exec -it -- bash -c 'DOMAINS=example.com foo.example.com ./save_certs.sh' 25 | ``` 26 | 27 | ### Refresh the certificates 28 | 29 | ``` 30 | kubectl exec -it -- bash -c 'EMAIL=fred@fred.com DOMAINS=example.com foo.example.com SECRET_NAME=foo DEPLOYMENTS=bar ./refresh_certs.sh' 31 | ``` 32 | 33 | ## Environment variables: 34 | 35 | - EMAIL - the email address to obtain certificates on behalf of. 36 | - DOMAINS - a space separated list of domains to obtain a certificate for. 37 | - LETSENCRYPT_ENDPOINT 38 | - If set, will be used to populate the /etc/letsencrypt/cli.ini file with 39 | the given server value. For testing use 40 | https://acme-staging.api.letsencrypt.org/directory 41 | - DEPLOYMENTS - a space separated list of deployments whose pods should be 42 | refreshed after a certificate save 43 | - SECRET_NAME - the name to save the secrets under 44 | - NAMESPACE - the namespace under which the secrets should be available 45 | - CRON_FREQUENCY - the 5-part frequency of the cron job. Default is a random 46 | time in the range `0-59 0-23 1-27 * *` 47 | -------------------------------------------------------------------------------- /check_new_certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get all certs that we manage and their expiration dates 4 | 5 | # Need to make sure the escaped single quotes work here instead of double quotes, have to parse env var for secret prefix 6 | #SECRET_PREFIX="cert" 7 | 8 | CERT_SECRET_NAMES=$(kubectl get secrets --namespace=storj-prod -o jsonpath=\'{.items[?(@.metadata.labels.type=="$SECRET_PREFIX")].metadata.name}\') 9 | 10 | # Trim off the prepending 'cert.' from each of the cert names 11 | 12 | # Get a list of secrets that contain certs 13 | 14 | # Remove certs from the list that we already have secrets for 15 | 16 | # If we still have some certs left, renew them all 17 | # should not cost us any extra hits on the rate limiter if this is done in one request 18 | 19 | -------------------------------------------------------------------------------- /fetch_certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | EMAIL=${EMAIL} 4 | DOMAINS=(${DOMAINS}) 5 | 6 | if [ -z "$DOMAINS" ]; then 7 | echo "ERROR: Domain list is empty or unset" 8 | exit 1 9 | fi 10 | 11 | if [ -z "$EMAIL" ]; then 12 | echo "ERROR: Email is empty string or unset" 13 | exit 1 14 | fi 15 | 16 | domain_args="" 17 | for i in "${DOMAINS[@]}" 18 | do 19 | domain_args="$domain_args -d $i" 20 | # do whatever on $i 21 | done 22 | 23 | /usr/local/bin/letsencrypt certonly \ 24 | --webroot -w /letsencrypt/challenges/ \ 25 | --text --renew-by-default --agree-tos \ 26 | $domain_args \ 27 | --email=$EMAIL 28 | -------------------------------------------------------------------------------- /nginx/letsencrypt.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name _; 3 | listen 80; 4 | 5 | location /.well-known/acme-challenge { 6 | alias /letsencrypt/challenges/.well-known/acme-challenge; 7 | } 8 | 9 | location / { 10 | return 200; 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes 1; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | 8 | events { 9 | worker_connections 1024; 10 | } 11 | 12 | 13 | http { 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 18 | '$status $body_bytes_sent "$http_referer" ' 19 | '"$http_user_agent" "$http_x_forwarded_for"'; 20 | 21 | access_log /var/log/nginx/access.log main; 22 | 23 | sendfile on; 24 | #tcp_nopush on; 25 | 26 | keepalive_timeout 65; 27 | 28 | #gzip on; 29 | client_max_body_size 20M; 30 | include /etc/nginx/conf.d/letsencrypt.conf; 31 | } 32 | -------------------------------------------------------------------------------- /recreate_pods.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Update the required env vars for the first pod in each Deployment. 4 | # This will kick off a rolling update. 5 | # Do this so that the secrets can be remounted. 6 | 7 | if [ -z "$DEPLOYMENTS" ]; then 8 | echo "WARNING: DEPLOYMENTS not provided. Secret changes may not be reflected." 9 | exit 10 | fi 11 | 12 | DEPLOYMENTS=(${DEPLOYMENTS}) 13 | DATE=$(date) 14 | NAMESPACE=${NAMESPACE:-default} 15 | 16 | for DEPLOYMENT in $DEPLOYMENTS 17 | do 18 | NAME=$(kubectl get deployments --namespace $NAMESPACE $DEPLOYMENT -o=template --template='{{index .spec.template.spec.containers 0 "name"}}') 19 | PATCH=$(NAME=$NAME DATE=$DATE echo "{\"spec\": {\"template\": {\"spec\": {\"containers\": [{\"name\": \"$NAME\", \"env\": [{\"name\": \"LETSENCRYPT_CERT_REFRESH\", \"value\": \"$DATE\"}]}]}}}}") 20 | echo "PATCHING ${DEPLOYMENT}: ${PATCH}" 21 | kubectl patch deployment --namespace $NAMESPACE $DEPLOYMENT --type=strategic -p "$PATCH" 22 | done 23 | -------------------------------------------------------------------------------- /refresh_certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Check to see if letsencrypt is running and do nothing if it is 5 | GREP=$(ps aux | grep refresh_certs | grep -v "grep") 6 | RESPONSE=$? 7 | if [[ $RESPONSE != 0 ]] 8 | then 9 | echo "Refresh script is already running. Exiting!" 10 | exit 1 11 | fi 12 | 13 | echo "$(date) Fetching certs..." 14 | /letsencrypt/fetch_certs.sh 15 | 16 | echo "$(date) Saving certs..." 17 | /letsencrypt/save_certs.sh 18 | 19 | echo "$(date) Recreating pods..." 20 | /letsencrypt/recreate_pods.sh 21 | -------------------------------------------------------------------------------- /save_certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # $DOMAINS should contain all domains that this container is responsible for 4 | # renewing. The first one dictates where the cert will live. 5 | 6 | # Inside /etc/letsencrypt/live/ we have: 7 | # 8 | # cert.pem chain.pem fullchain.pem privkey.pem 9 | # 10 | # We want to convert fullchain.pem into proxycert 11 | # and privkey.pem into proxykey and then save as a secret! 12 | 13 | if [ -z "$SECRET_PREFIX" ]; then 14 | SECRET_PREFIX=cert 15 | fi 16 | 17 | 18 | CERT_LOCATION='/etc/letsencrypt/live' 19 | 20 | DOMAIN_ARRAY=($DOMAINS) 21 | MAIN_DOMAIN=${DOMAIN_ARRAY[0]} 22 | 23 | for DOMAIN in $DOMAINS; do 24 | SECRET_NAME="$SECRET_PREFIX.$DOMAIN" 25 | 26 | CERT=$(cat $CERT_LOCATION/$MAIN_DOMAIN/fullchain.pem | base64 --wrap=0) 27 | KEY=$(cat $CERT_LOCATION/$MAIN_DOMAIN/privkey.pem | base64 --wrap=0) 28 | DHPARAM=$(openssl dhparam 2048 | base64 --wrap=0) 29 | 30 | NAMESPACE=${NAMESPACE:-default} 31 | 32 | EXPIRE_DATE=$(openssl x509 -enddate -noout -in $CERT_LOCATION/$MAIN_DOMAIN/fullchain.pem | awk -F "=" '{print $2}' | base64 --wrap=0) 33 | 34 | kubectl get secrets --namespace $NAMESPACE $SECRET_NAME && ACTION=replace || ACTION=create; 35 | 36 | cat << EOF | kubectl $ACTION -f - 37 | { 38 | "apiVersion": "v1", 39 | "kind": "Secret", 40 | "metadata": { 41 | "labels": { 42 | "type": "cert" 43 | }, 44 | "name": "$SECRET_NAME", 45 | "namespace": "$NAMESPACE" 46 | }, 47 | "data": { 48 | "tls.crt": "$CERT", 49 | "tls.key": "$KEY", 50 | "tls.dhparam": "$DHPARAM", 51 | "tls.expires": "$EXPIRE_DATE" 52 | } 53 | } 54 | EOF 55 | 56 | done 57 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Add a cron line with details of the current user etc 4 | minute=$(echo $RANDOM % 60 | bc) 5 | hour=$(echo $RANDOM % 23 | bc) 6 | day=$(echo $RANDOM % 27 + 1 | bc) 7 | 8 | CRON_REFRESH_FREQUENCY=${CRON_FREQUENCY:-"$minute $hour $day * *"} 9 | CRON_NEW_CHECK_FREQUENCY=${CRON_FREQUENCY:-"* * * * *"} 10 | NAMESPACE=${NAMESPACE:-default} 11 | 12 | echo "Configuring cron..." 13 | echo "DOMAINS: " $DOMAINS 14 | echo "EMAIL: " $EMAIL 15 | echo "DEPLOYMENTS: " $DEPLOYMENTS 16 | echo "NAMESPACE: " $NAMESPACE 17 | echo "SECRET_NAME: " $SECRET_NAME 18 | echo "CRON frequency: " $CRON_FREQUENCY 19 | # Once a month, fetch and save certs + restart pods. 20 | 21 | # The process running under cron needs to know where the to find the kubernetes api 22 | env_vars="PATH=$PATH KUBERNETES_PORT=$KUBERNETES_PORT KUBERNETES_PORT_443_TCP_PORT=$KUBERNETES_PORT_443_TCP_PORT KUBERNETES_SERVICE_PORT=$KUBERNETES_SERVICE_PORT KUBERNETES_SERVICE_HOST=$KUBERNETES_SERVICE_HOST KUBERNETES_PORT_443_TCP_PROTO=$KUBERNETES_PORT_443_TCP_PROTO KUBERNETES_PORT_443_TCP_ADDR=$KUBERNETES_PORT_443_TCP_ADDR KUBERNETES_PORT_443_TCP=$KUBERNETES_PORT_443_TCP" 23 | 24 | refresh_cron="$CRON_FREQUENCY $env_vars SECRET_NAME=$SECRET_NAME NAMESPACE=$NAMESPACE DEPLOYMENTS='$DEPLOYMENTS' DOMAINS='$DOMAINS' EMAIL=$EMAIL /bin/bash /letsencrypt/refresh_certs.sh >> /var/log/cron-encrypt.log 2>&1" 25 | (crontab -u root -l; echo "$refresh_cron" ) | crontab -u root - 26 | 27 | new_cert_cron="$CRON_FREQUENCY $env_vars SECRET_NAME=$SECRET_NAME NAMESPACE=$NAMESPACE DEPLOYMENTS='$DEPLOYMENTS' DOMAINS='$DOMAINS' EMAIL=$EMAIL /bin/bash /letsencrypt/check_new_certs.sh >> /var/log/cron-encrypt.log 2>&1" 28 | (crontab -u root -l; echo "$new_cert_cron" ) | crontab -u root - 29 | 30 | if [ -n "${LETSENCRYPT_ENDPOINT+1}" ]; then 31 | echo "server = $LETSENCRYPT_ENDPOINT" >> /etc/letsencrypt/cli.ini 32 | fi 33 | 34 | # Start cron 35 | echo "Starting cron..." 36 | cron & 37 | 38 | echo "Starting nginx..." 39 | nginx -g 'daemon off;' 40 | --------------------------------------------------------------------------------