/ Certbot

Automating JKS Certificate Renewal

Automating JKS file generation via Bash

This is part 2 / LetsEncrypt + Salesforce Communities

Update, I will be creating a new post outlining using Acme.sh instead of CertBot. Acme.sh has better scripting support from a headless client like Jenkins.

This is just a proof of concept and should not be implemented in production due to API security limitations with this design. There are ways to secure the certificate generation process, such as using the dns-cloudflare plugin for certbot, but that requires a somewhat annoying plugin installation process. I will update this post after the cloudflare plugin is included in certbot by default. For now, the Cloudflare integration requires a shell script, which actually works pretty well.

There are two ways to do this:

  1. Cloudflare DNS updates
  2. VisualForce + REST API

Both methods involve storing some credentials on disk, however, when automating with Jenkins, you can store those credentials securely and pass them into the shell as environment variables. It's completely up to you how to implement the credential store. That said, the VisualForce method outlined below does not require any credentials and is not recommended because of the security implications behind that design.

Method 1: Cloudflare using manual hooks

This is my preferred method as it does not require me to open up a REST API Endpoint on the Salesforce side (or worry about configuring JWT OAuth into the flow).

To use Cloudflare we will be utilizing a manual-auth-hook and manual-cleanup-hook which are two shell scripts that will call the Cloudflare API to create a new TXT record. To do this, you will need to have a Cloudflare API key.

Cloudflare Config: cf_config.sh

#!/bin/bash

# Get your API key from https://www.cloudflare.com/a/profile
CF_API_KEY="your-cloudflare-api-key"
CF_EMAIL="your-cloudflare-email"

Manual-auth-hook: cf_auth.sh

#!/bin/bash
# requires (python or jq) and curl
# brew install jq
#https://certbot.eff.org/docs/using.html#pre-and-post-validation-hooks
source cf_config.sh
API_KEY=$CF_API_KEY
EMAIL=$CF_EMAIL
CFAPI="https://api.cloudflare.com/client/v4/zones"

# Strip only the top domain to get the zone id
DOMAIN=$(expr "$CERTBOT_DOMAIN" : '.*\.\(.*\..*\)')

# Get the Cloudflare zone id
ZONE_EXTRA_PARAMS="status=active&page=1&per_page=20&order=status&direction=desc&match=all"
ZONE_ID=$(curl -s -X GET "$CFAPI?name=$DOMAIN&$ZONE_EXTRA_PARAMS" \
     -H     "X-Auth-Email: $EMAIL" \
     -H     "X-Auth-Key: $API_KEY" \
     -H     "Content-Type: application/json" \
            | python -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
            # | jq -r '.result[].id')

# Create TXT record
CREATE_DOMAIN="_acme-challenge.$CERTBOT_DOMAIN"
JSONDATA=$(cat <<EOF
{
        "type":"TXT",
        "name":"$CREATE_DOMAIN",
        "content":"$CERTBOT_VALIDATION",
        "ttl":120
}
EOF
)
echo $JSONDATA
RECORD_ID=$(curl -s -X POST "$CFAPI/$ZONE_ID/dns_records" \
     -H     "X-Auth-Email: $EMAIL" \
     -H     "X-Auth-Key: $API_KEY" \
     -H     "Content-Type: application/json" \
     -d     "$JSONDATA" \
        | python -c "import sys,json;print(json.load(sys.stdin)['result']['id'])")

# Save info for cleanup
if [ ! -d tmp ];then
        mkdir -m 0700 tmp
fi
if [ ! -d tmp/CERTBOT_$CERTBOT_DOMAIN ];then
        mkdir -m 0700 tmp/CERTBOT_$CERTBOT_DOMAIN
fi
echo $ZONE_ID > tmp/CERTBOT_$CERTBOT_DOMAIN/ZONE_ID
echo $RECORD_ID > tmp/CERTBOT_$CERTBOT_DOMAIN/RECORD_ID

# Sleep to make sure the change has time to propagate over to DNS
sleep 25#!/bin/bash

source cf_config.sh
API_KEY=$CF_API_KEY
EMAIL=$CF_EMAIL

CFAPI="https://api.cloudflare.com/client/v4/zones"

if [ -f tmp/CERTBOT_$CERTBOT_DOMAIN/ZONE_ID ]; then
        ZONE_ID=$(cat tmp/CERTBOT_$CERTBOT_DOMAIN/ZONE_ID)
fi

if [ -f tmp/CERTBOT_$CERTBOT_DOMAIN/RECORD_ID ]; then
        RECORD_ID=$(cat tmp/CERTBOT_$CERTBOT_DOMAIN/RECORD_ID)
fi

if [ -d tmp/CERTBOT_$CERTBOT_DOMAIN ]; then
        rm -rf tmp/CERTBOT_$CERTBOT_DOMAIN
fi

# Remove the challenge TXT record from the zone
if [ -n "${ZONE_ID}" ]; then
    if [ -n "${RECORD_ID}" ]; then
        curl -s -X DELETE "$CFAPI/$ZONE_ID/dns_records/$RECORD_ID" \
                -H "X-Auth-Email: $EMAIL" \
                -H "X-Auth-Key: $API_KEY" \
                -H "Content-Type: application/json"
    fi
fi

CERTBOT_DOMAIN="$CERTBOT_DOMAIN" \
    RENEWED_LINEAGE="./config/live/$CERTBOT_DOMAIN" \
    ./makeJKS.sh > ./$CERTBOT_DOMAIN.jks.log 2>&1 &

Manual-cleanup-hook: cf_cleanup.sh

#!/bin/bash

# Get your API key from https://www.cloudflare.com/a/profile
API_KEY="your-cloudflare-api-key"
EMAIL="your-cloudflare-username" # Probably your email address
CFAPI="https://api.cloudflare.com/client/v4/zones"

if [ -f tmp/CERTBOT_$CERTBOT_DOMAIN/ZONE_ID ]; then
        ZONE_ID=$(cat tmp/CERTBOT_$CERTBOT_DOMAIN/ZONE_ID)
fi

if [ -f tmp/CERTBOT_$CERTBOT_DOMAIN/RECORD_ID ]; then
        RECORD_ID=$(cat tmp/CERTBOT_$CERTBOT_DOMAIN/RECORD_ID)
fi

if [ -d tmp/CERTBOT_$CERTBOT_DOMAIN ]; then
        rm -rf tmp/CERTBOT_$CERTBOT_DOMAIN
fi

# Remove the challenge TXT record from the zone
if [ -n "${ZONE_ID}" ]; then
    if [ -n "${RECORD_ID}" ]; then
        curl -s -X DELETE "$CFAPI/$ZONE_ID/dns_records/$RECORD_ID" \
                -H "X-Auth-Email: $EMAIL" \
                -H "X-Auth-Key: $API_KEY" \
                -H "Content-Type: application/json"
    fi
fi

echo "Creating JKS file..."

CERTPATH="./config/live/$CERTBOT_DOMAIN/"
if [ -d $CERTPATH ]; then
    
    echo "CERTPATH=$CERTPATH"

    FULLCHAIN=$CERTPATH"fullchain.pem"
    KEY=$CERTPATH"privKey.pem"
    if [ -z "$CERTBOT_TOKEN" ]; then
        CERTBOT_TOKEN=$(openssl rand -base64 32)
    fi

    echo "Creating pkcs12 store..."

    openssl pkcs12 -export \
        -in $FULLCHAIN \
        -inkey $KEY \
        -name "certbot_autogen" \
        -out $CERTBOT_DOMAIN.p12 \
        -password "pass:$CERTBOT_TOKEN"
    
    if [ -f $CERTBOT_DOMAIN.jks ]; then
        rm -f $CERTBOT_DOMAIN.jks
    fi

    echo "Creating JKS store..."
    keytool -noprompt \
        -importkeystore \
        -deststorepass $CERTBOT_TOKEN \
        -destkeystore $CERTBOT_DOMAIN.jks \
        -srckeystore $CERTBOT_DOMAIN.p12 \
        -srcstorepass $CERTBOT_TOKEN \
        -srcstoretype PKCS12 > /dev/null 2>&1

    if [ -f $CERTBOT_DOMAIN.p12 ]; then
        rm -f $CERTBOT_DOMAIN.p12
    fi

    echo "JKS PASSWORD=$CERTBOT_TOKEN"

else
    echo "ERROR - Certpath is invalid"
fi

Build JKS: makeJS.sh

#!/bin/bash

sleep 5
echo "CERTBOT_DOMAIN = $CERTBOT_DOMAIN"
# echo "RENEWED_LINEAGE = $RENEWED_LINEAGE"
CERTPATH="$RENEWED_LINEAGE"
if [ -z $CERTPATH ]; then
echo "CERTPATH was blank"
exit 1
fi
if [ -d $CERTPATH ]; then
    # echo "Creating JKS file..."
    echo "CERTPATH = $CERTPATH"

    FULLCHAIN=$CERTPATH"/fullchain.pem"
    KEY=$CERTPATH"/privKey.pem"
    if [ -z "$CERTBOT_TOKEN" ]; then
        CERTBOT_TOKEN=$(openssl rand -base64 32)
    fi

    # echo "Creating pkcs12 store..."

    openssl pkcs12 -export \
        -in $FULLCHAIN \
        -inkey $KEY \
        -name "certbot_autogen" \
        -out $CERTBOT_DOMAIN.p12 \
        -password "pass:$CERTBOT_TOKEN"
    
    if [ -f $CERTBOT_DOMAIN.jks ]; then
        rm -f $CERTBOT_DOMAIN.jks
    fi

    # echo "Creating JKS store..."
    keytool -noprompt \
        -importkeystore \
        -deststorepass $CERTBOT_TOKEN \
        -destkeystore $CERTBOT_DOMAIN.jks \
        -srckeystore $CERTBOT_DOMAIN.p12 \
        -srcstorepass $CERTBOT_TOKEN \
        -srcstoretype PKCS12 > /dev/null 2>&1

    if [ -f $CERTBOT_DOMAIN.p12 ]; then
        rm -f $CERTBOT_DOMAIN.p12
    fi

    echo "JKS PASSWORD=$CERTBOT_TOKEN"

else
    echo "ERROR - Certpath is invalid"
fi

Method 2: VisualForce + REST API

I recommend enhancing the shell scripts below to implement JWT Bearer Tokens to secure the REST API endpoint and skipping step 2

Step 1: Create a REST Service to accept curl updates for token validation (INSECURE). I added this to the VF Page controller

// This API Endpoint will update the custom setting to store the token for certbot validation
@RestResource(urlMapping='/Certbot/v1/*')
global class certbotController {

    public String getRequestResponse() {
        string suffix = Apexpages.currentPage().getParameters().get('suffix');
        string token = certbot__c.getOrgDefaults().Value__c;
        if(token.startsWith(suffix)){
            return token;
        }
        return suffix + token;
    }
    
    @HttpPut
    global static string updateValidation(string input){
        certbot__c def = certbot__c.getOrgDefaults();
        def.Value__c = input;
        update def;
        return certbot__c.getOrgDefaults().Value__c;
    }
    
}

Step 2: Give the Community Public Profile access to this APEX class
Step 3: Create a hook.sh script to PUT the certbot validation token in Salesforce by calling the REST API service via curl

#!/bin/bash
JSONDATA="{ \"input\" : \"$CERTBOT_VALIDATION\" }"
RESPONSE=$(curl -s \
    -X PUT "https://$CERTBOT_DOMAIN/services/apexrest/Certbot/v1/updateValidation" \
    -H "Content-Type: application/json" \
    -d "$JSONDATA" )

Step 4: Create a cleanup.sh script to build the JKS file

#!/bin/bash
CERTPATH="./config/live/$CERTBOT_DOMAIN/"
if [ -d $CERTPATH ]; then
    
    echo "CERTPATH=$CERTPATH"

    FULLCHAIN=$CERTPATH"fullchain.pem"
    KEY=$CERTPATH"privKey.pem"
    if [ -z "$CERTBOT_TOKEN" ]; then
        CERTBOT_TOKEN=$(openssl rand -base64 32)
    fi

    echo "Creating pkcs12 store..."

    openssl pkcs12 -export \
        -in $FULLCHAIN \
        -inkey $KEY \
        -name "certbot_autogen" \
        -out $CERTBOT_DOMAIN.p12 \
        -password "pass:$CERTBOT_TOKEN"
    
    if [ -f $CERTBOT_DOMAIN.jks ]; then
        rm -f $CERTBOT_DOMAIN.jks
    fi

    echo "Creating JKS store..."
    keytool -noprompt \
        -importkeystore \
        -deststorepass $CERTBOT_TOKEN \
        -destkeystore $CERTBOT_DOMAIN.jks \
        -srckeystore $CERTBOT_DOMAIN.p12 \
        -srcstorepass $CERTBOT_TOKEN \
        -srcstoretype PKCS12 > /dev/null 2>&1

    if [ -f $CERTBOT_DOMAIN.p12 ]; then
        rm -f $CERTBOT_DOMAIN.p12
    fi

    echo "JKS PASSWORD=$CERTBOT_TOKEN"

else
    echo "ERROR - Certpath is invalid"
fi

Configuring Certbot defaults

For both methods, I'm using a shared certbot config file, which I specify when running the certbot command. This is completely optional as all of the flags in the config file can actually be specified in the certbot command.

Certbot config: config.ini

# Directory to store the certificates and config
config-dir = /Users/gtandeciarz/certbot/config
work-dir = /Users/gtandeciarz/certbot/config
logs-dir = /Users/gtandeciarz/certbot/logs

# Certbot plugin authenticator
# manual mode enables us to specify using Cloudflare DNS or
# VisualForce methods
authenticator = manual

# The location of the hooks to execute during renewal
manual-auth-hook = /Users/gtandeciarz/certbot/cf_auth.sh
manual-cleanup-hook = /Users/gtandeciarz/certbot/cf_cleanup.sh

# Flags to remove any interactive requirements
agree-tos
no-eff-email
manual-public-ip-logging-ok
keep-until-expiring
quiet

Now, when I'm ready to test, I can call certbot certonly -n --dry-run from the directory config parent directory. In my case, I execute:

$ cd ~/certbot
$ certbot certonly -n --dry-run -c config.ini \
    -d subdomain.example.com \
    --email [email protected] \
    --quiet

Now that we have our JKS file, we are ready to upload it to our Salesforce org. Part 3 of this series will cover that portion.

All shell scripts mentioned above are available on github