User Tools

Site Tools


indexes:let_s_encrypt_-_how_to_issue_certificates_from_a_bigip

Let's Encrypt on a BigIP

With the opening of the public beta we can now all make use of trusted certificates in our applications free of charge. This is nice! Let's Encrypt is no longer in beta (https://letsencrypt.org/2016/04/12/leaving-beta-new-sponsors.html) but that doesn't change much, you can still get hands of a lot of fully trusted certificates for your applications and websites, and it is still super nice!! ^_^

Let's Encrypt is a great project with a new approach to certificates and how to secure and manage them. This new approach however forces us to think a little bit differently when we work with them. Normally this was a task that took place once a year and could easily be handled by hand. This is not the case with Let's Encrypt. Each certificate only has a lifespan of 90 days and for the time being we do not have access to wildcard certificates, only SAN. So for each domain we will have to change all the certificates every three months, and that is simply not feasible if done manually.

So here comes certificate automation. The project has built a client making automation possible when running on a generic Linux server, and that is fine. However it does make it a little more problematic if you don't have the certificates on the backend servers but on an ADC, F5 Big-IP in this case. This got me thinking, how do we fix this? Luckily I got some very talented colleagues (thank you Dindorp!) with an equal desire for Let's Encrypt, so here is one way of doing it!

Requirements

As this solution is based on pure bash scripts we have very few dependencies, and that is why I've chosen to go this way. Also the main script handling the requesting runs without any changes on a BigIP. The big part of the scripting has already been done by Lukas Schauer - Great job Lukas!!!

Besides the ability to control a keyboard and a SSH client LOL you of course needs to have access to a BigIP with admin rights and an advanced shell (F5 terminology for a Bash shell).

The idea is to use crontab for the automation, so you need to hack this for your requirements. The crontab on a BigIP is no different than on a generic Linux server, so nothing magical here.

On the BigIP you must have a virtual server listening on port 80/tcp that the domain resolves to. This VS is what we use as a reverse proxy for the challenge-response validation mechanism that Let's Encrypt is based upon. You probably have this already and you can just reuse it. As we have the logic tied to an iRule, you just have to make sure that the iRule is the first thing being executed so current logic doesn't break the challenge-response communication.

Another important (and obvious ^_^) requirement is when you have a HA pair, you must make sure that the scripts only run on the unit which is active for the traffic-group. Otherwise the changes wouldn't make much sense as the challenge-response traffic will never reach the configured virtual server/data group. I've made a wrapper script for inspiration that you can put into the crontab on all the units.

Limitations / Todo's

Before you start to fire away with requests please be aware of these restrictions that is in place currently: https://community.letsencrypt.org/t/rate-limits-for-lets-encrypt/6769

I've been hit by them a couple of time now in my eagerness to test :-) They should be loosened over time but when and to what extent I do not know.

For now I haven't put in any cleanup of old and expired certificates, so they're just gonna pile up. So from time to time you need to go in and remove expired ones. This shouldn't be a too big deal as they are taken out of the client ssl profile automatically. So a simple sort-by-expiry and you can delete them in bunches.

The scripts are tested on TMOS version 12.1 but should work across other versions. The limitation is gonna be the tmsh commands in the hook script, the rest is taken directly from GitHub.

I've left the legacy hook and config script for reference. They don't work anymore so don't use them!

Data Group

When you initiate the certificate requests the authentication is based on a challenge-response to prove you own or control the domain name. For this to work we utilize a data group to contain the challenge-response values that are generated through the script. This bridges the script values with the iRule and allows for easy and dynamic access to it.

Here is the tmsh command to create it:

tmsh create ltm data-group internal acme_responses type string

iRule

The iRule works as the webserver/reverse proxy for the challenge-response communication. It features some simple logic that basically looks for the challenge URI. If found it searches, in the above mentioned data group, and if a match is found builds the correct response for ACME. Here it is of course important that other functions or logic doesn't interfere or return other values or challenges (like ASM DoS profile). Copy the iRule below and attach it to the proper virtual server which hosts the domain(s).

when HTTP_REQUEST {
		if { not ([HTTP::path] starts_with "/.well-known/acme-challenge/") } { return }
		set token [lindex [split [HTTP::path] "/"] end]
		set response [class match -value -- $token equals acme_responses]
		if { "$response" == "" } {
			log local0. "Responding with 404 to ACME challenge $token"
			HTTP::respond 404 content "Challenge-response token not found."
		} else {
			log local0. "Responding to ACME challenge $token with response $response"
			HTTP::respond 200 content "$response" "Content-Type" "text/plain; charset=utf-8"
		}
	}

Client SSL Profiles

When the certificate has been signed and returned the hook script will apply it to the F5 configuration through a set of tmsh commands. These commands has some assumptions. First of all it has to exist beforehand and secondly it must have the name convention as this: “auto_${DOMAIN}”

An example. If you have the domain “example.com” then the profile should be named “auto_example.com”.

The hook script simply replaces the certificate and key files already in place. You can apply whatever settings to the profile you like.

If you have the “domain.txt” file populated (see below for explanations on domain.txt) this script will create the needed clientssl profiles for you in one quick go:

#!/bin/bash
for i in $( cat domains.txt | awk '{ print $1}' ); do
  tmsh create ltm profile client-ssl auto_$i
  echo "Created  auto_$i client-ssl profile"
done

domains.txt

For the domains you would like to register a certificate for you insert them into the domain.txt file. You can have as many as you like (see restrictions above!). The format is important though.

example.com www.example.com
example.dk wiki.example.dk
example.se upload.example.se download.example.se

From the above example you can see that the “base” domain must be first followed by subdomains that will go in to the certificate as SAN names. Remember that all the names you put in here must resolve to the virtual server that handles the challenge-response validation mentioned previously. All names are validated. The above domain example will generate three certificates.

hook.sh

The hook script has changed from the initial release of the letsencrypt script. You must use this version and not the legacy one (it simply doesn't work anymore). The logic is the same though.

#!/usr/bin/env bash

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

    # This hook is called once for every domain that needs to be
    # validated, including any alternative names you may have listed.
    #
    # Parameters:
    # - DOMAIN
    #   The domain name (CN or subject alternative name) being
    #   validated.
    # - TOKEN_FILENAME
    #   The name of the file containing the token to be served for HTTP
    #   validation. Should be served by your web server as
    #   /.well-known/acme-challenge/${TOKEN_FILENAME}.
    # - TOKEN_VALUE
    #   The token value that needs to be served for validation. For DNS
    #   validation, this is what you want to put in the _acme-challenge
    #   TXT record. For HTTP validation it is the value that is expected
    #   be found in the $TOKEN_FILENAME file.
    cmd='tmsh modify ltm data-group internal acme_responses records add \{ "'$TOKEN_FILENAME'" \{ data "'$TOKEN_VALUE'" \} \}'
    $cmd
}

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

    # This hook is called after attempting to validate each domain,
    # whether or not validation was successful. Here you can delete
    # files or DNS records that are no longer needed.
    #
    # The parameters are the same as for deploy_challenge.
    cmd='tmsh modify ltm data-group internal acme_responses records delete \{ "'$TOKEN_FILENAME'" \}'
    $cmd
}

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

    # This hook is called once for each certificate that has been
    # produced. Here you might, for instance, copy your new certificates
    # to service-specific locations and reload the service.
    #
    # Parameters:
    # - DOMAIN
    #   The primary domain name, i.e. the certificate common
    #   name (CN).
    # - KEYFILE
    #   The path of the file containing the private key.
    # - CERTFILE
    #   The path of the file containing the signed certificate.
    # - FULLCHAINFILE
    #   The path of the file containing the full certificate chain.
    # - CHAINFILE
    #   The path of the file containing the intermediate certificate(s).
    # - TIMESTAMP
    #   Timestamp when the specified certificate was created.
    now=$(date +%Y-%m-%d)
    profile=auto_${DOMAIN}
    name=${DOMAIN}_${now}
    cert=${name}.crt
    key=${name}.key
    tmsh install sys crypto key ${name} from-local-file ${KEYFILE}
    tmsh install sys crypt cert ${name} from-local-file ${FULLCHAINFILE}
    tmsh modify ltm profile client-ssl ${profile} cert-key-chain replace-all-with { default { key $key cert $cert } }
}

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

    # This hook is called once for each certificate that is still
    # valid and therefore wasn't reissued.
    #
    # Parameters:
    # - DOMAIN
    #   The primary domain name, i.e. the certificate common
    #   name (CN).
    # - KEYFILE
    #   The path of the file containing the private key.
    # - CERTFILE
    #   The path of the file containing the signed certificate.
    # - FULLCHAINFILE
    #   The path of the file containing the full certificate chain.
    # - CHAINFILE
    #   The path of the file containing the intermediate certificate(s).
}

HANDLER=$1; shift; $HANDLER $@

hook.sh (legacy - don't use!)

Don't use this version of the hook script. It is only still around for reference.

The hook works as a link between the main script (letsencrypt.sh) and the BigIP. For each step of the request process the hook script is called with a command that either sets up the challenge-response logic (insert the values into the data group), installs the certificates (setup certificates and keys in the clientssl profile) or cleanup the challenge-response values created in the process from the data group.

#!/bin/bash

command=$1; shift
domain=$1; shift
token=$1; shift
response=$1; shift

echo "command: $command"
echo "domain: $domain"
#echo "token: $token"
#echo "response: $response"

if [ "$command" == "deploy_challenge" ]; then
	cmd='tmsh modify ltm data-group internal acme_responses records add \{ "'$token'" \{ data "'$response'" \} \}'
	$cmd
fi

if [ "$command" == "clean_challenge" ]; then
	cmd='tmsh modify ltm data-group internal acme_responses records delete \{ "'$token'" \}'
	$cmd
fi

if [ "$command" == "deploy_cert" ]; then
	now=$(date +%Y-%m-%d)
	name=${domain}_${now}
	cert=${name}.crt
	key=${name}.key
	profile=auto_${domain}
	cp -f ${BASEDIR}/certs/$domain/privkey.pem /config/ssl/ssl.key/$key
	cp -f ${BASEDIR}/certs/$domain/fullchain.pem /config/ssl/ssl.crt/$cert
	tmsh install sys crypto key ${name} from-local-file /config/ssl/ssl.key/$key
	tmsh install sys crypt cert ${name} from-local-file /config/ssl/ssl.crt/$cert
	tmsh modify ltm profile client-ssl ${profile} cert-key-chain replace-all-with { default { key $key cert $cert } }
fi

config

Another change to the script is the config file. It has changed its name and gotten some new features. I dont' make use of them, so again it is only minor changes. I assume the hook file is in the same directory as the letsencrypt script.

########################################################
# This is the main config file for letsencrypt.sh      #
#                                                      #
# This file is looked for in the following locations:  #
# $SCRIPTDIR/config (next to this script)              #
# /usr/local/etc/letsencrypt.sh/config                 #
# /etc/letsencrypt.sh/config                           #
# ${PWD}/config (in current working-directory)         #
#                                                      #
# Default values of this config are in comments        #
########################################################

# Path to certificate authority (default: https://acme-v01.api.letsencrypt.org/directory)
#CA="https://acme-v01.api.letsencrypt.org/directory"

# Path to license agreement (default: https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf)
#LICENSE="https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf"

# Which challenge should be used? Currently http-01 and dns-01 are supported
CHALLENGETYPE="http-01"

# Path to a directory containing additional config files, allowing to override
# the defaults found in the main configuration file. Additional config files
# in this directory needs to be named with a '.sh' ending.
# default: <unset>
#CONFIG_D=

# Base directory for account key, generated certificates and list of domains (default: $SCRIPTDIR -- uses config directory if undefined)
#BASEDIR=$SCRIPTDIR

# File containing the list of domains to request certificates for (default: $BASEDIR/domains.txt)
#DOMAINS_TXT="${BASEDIR}/domains.txt"

# Output directory for generated certificates
#CERTDIR="${BASEDIR}/certs"

# Output directory for challenge-tokens to be served by webserver or deployed in HOOK (default: $BASEDIR/.acme-challenges)
#WELLKNOWN="${BASEDIR}/.acme-challenges"

# Location of private account key (default: $BASEDIR/private_key.pem)
#ACCOUNT_KEY="${BASEDIR}/private_key.pem"

# Location of private account registration information (default: $BASEDIR/private_key.json)
#ACCOUNT_KEY_JSON="${BASEDIR}/private_key.json"

# Default keysize for private keys (default: 4096)
#KEYSIZE="4096"

# Path to openssl config file (default: <unset> - tries to figure out system default)
#OPENSSL_CNF=

# Program or function called in certain situations
#
# After generating the challenge-response, or after failed challenge (in this case altname is empty)
# Given arguments: clean_challenge|deploy_challenge altname token-filename token-content
#
# After successfully signing certificate
# Given arguments: deploy_cert domain path/to/privkey.pem path/to/cert.pem path/to/fullchain.pem
#
# BASEDIR and WELLKNOWN variables are exported and can be used in an external program
# default: <unset>
HOOK="${BASEDIR}/hook.sh"

# Chain clean_challenge|deploy_challenge arguments together into one hook call per certificate (default: no)
#HOOK_CHAIN="no"

# Minimum days before expiration to automatically renew certificate (default: 30)
#RENEW_DAYS="30"

# Regenerate private keys instead of just signing new certificates on renewal (default: yes)
#PRIVATE_KEY_RENEW="yes"

# Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1
#KEY_ALGO=rsa

# E-mail to use during the registration (default: <unset>)
#CONTACT_EMAIL=example@example.com

# Lockfile location, to prevent concurrent access (default: $BASEDIR/lock)
#LOCKFILE="${BASEDIR}/lock"

# Option to add CSR-flag indicating OCSP stapling to be mandatory (default: no)
#OCSP_MUST_STAPLE="no"

config.sh (legacy - don't use!)

Don't use this config file. It is only still around for reference.

The config.sh file is basically where you will insert settings that you would like to divert from the default values. It also helps keeping the main script clean and easy to update. I want to generate a new private key each time (PRIVATE_KEY_RENEW=“yes”) and of course we need to specify the location of the hook script (HOOK=./hook.sh).

#!/bin/bash

########################################################
# This is the config file for letsencrypt.sh           #
#                                                      #
# This file is looked for in the following locations:  #
# $SCRIPTDIR/config.sh (next to this script)           #
# ${HOME}/.letsencrypt.sh/config.sh (in user home)     #
# /usr/local/etc/letsencrypt.sh/config.sh              #
# /etc/letsencrypt.sh/config.sh                        #
# ${PWD}/config.sh (in current working-directory)      #
#                                                      #
# Default values of this config are in comments        #
########################################################

# Path to certificate authority (default: https://acme-v01.api.letsencrypt.org/directory)
#CA="https://acme-v01.api.letsencrypt.org/directory"

# Path to license agreement (default: https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf)
#LICENSE="https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf"

# Output directory for challenge-tokens to be served by webserver or deployed in HOOK (default: $SCRIPTDIR/.acme-challenges)
#WELLKNOWN="${SCRIPTDIR}/.acme-challenges"

# Default keysize for private keys (default: 4096)
#KEYSIZE="4096"

# Base directory for account key, generated certificates and list of domains (default: $SCRIPTDIR -- uses config directory if undefined)
#BASEDIR=$SCRIPTDIR

# Path to openssl config file (default: <unset> - tries to figure out system default)
#OPENSSL_CNF=

# Name of root certificate (default: lets-encrypt-x1-cross-signed.pem)
#ROOTCERT="lets-encrypt-x1-cross-signed.pem"

# Program or function called in certain situations
#
# After generating the challenge-response, or after failed challenge (in this case altname is empty)
# Given arguments: clean_challenge|deploy_challenge altname token-filename token-content
#
# After successfully signing certificate
# Given arguments: deploy_cert domain path/to/privkey.pem path/to/cert.pem path/to/fullchain.pem
#
# BASEDIR and WELLKNOWN variables are exported and can be used in an external program
# default: <unset>
HOOK=./hook.sh

# Minimum days before expiration to automatically renew certificate (default: 14)
#RENEW_DAYS="14"

# Regenerate private keys instead of just signing new certificates on renewal (default: no)
PRIVATE_KEY_RENEW="yes"

# E-mail to use during the registration (default: <unset>)
#CONTACT_EMAIL=

letsencrypt.sh

This is the main script and where you initiate the actions from. Unless you make use of a wrapper script, this is the script you put into the crontab.

You run it like this: “letsencrypt.sh -c”

Sources, References and Files

Given what ever skills I possess this Let's Encrypt automation scripting on a BigIP would never have been possible without the knowledge of Lukas Schauer and David Dindorp - Thank you guys, you are amazing!!!

You can find the scripts on GitHub here: https://github.com/lukas2511/letsencrypt.sh

Scripts collected in one zip file: Scripts, hook, config and wrapper

And the hook and the modifications here (legacy): Scripts, hook and config file.

indexes/let_s_encrypt_-_how_to_issue_certificates_from_a_bigip.txt · Last modified: d/m/Y H:i by domingo