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:
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