Let’s Encrypt provide a useful alternative challenge protocol called DNS-01 which allows services to renew SSL certificates without accepting inbound connections from the Internet. This proves to be quite useful if you’re running LAN only services but still wish to deploy SSL internally via Let’s Encrypt.

There’s ample information about Let’s Encrypt and DNS-01 on-line but having recently configured it on FreeNAS I wanted to share my notes.

A single “dehydrated” jail will be responsible for generating/renewing all the domain certificates needed by any other jails/VMs on the FreeNAS box. The resulting certificates will be kept in the mounted storage “/mnt/tank/services/dehydrated/certs/[DOMAIN_NAME]/” and other jails will have only the domain_name directory they need mounted read-only.

Prerequisites

You need your DNS hosting with a company that provides an API to create/remove TXT records. Although there are ways to script around the issue with other DNS hosts that’s not covered here. There’s a selection of suitable hosts on the lexicon site, I chose NS1.

Clone the following git repos somewhere, a few files will need transfering from these into a FreeNAS jail later.

git clone https://github.com/lukas2511/dehydrated.git
git clone https://github.com/AnalogJ/lexicon.git

Jail

Create a new FreeNAS user with the name “dehydrated” and note the UID it’s assigned. This user does not need a home directory, “nologin” as their shell and disable password login.

Create a new jail called “dehydrated” and setup a “dehydrated” user within it that has a matching UID. Again this user should have no home directory, nologin shell and disabled password login.

Create some storage in “/mnt/tank/services/dehydrated” (or another suitable path) and mount this in the dehydrated jail as “/opt/dehydrated”.

Enter the “dehydrated” jail

jexec dehydrated /bin/tcsh

Dehydrated Setup

The dehydrated jail will periodically run dehydrated with the DNS-01 protocol to renew SSL certificates. Dehydrated will invoke callbacks in a “hook” file that in turn will run dns-lexicon to interact with your DNS provider’s API service to handle the dns-01 challenge.

cd /opt/dehydrated
mkdir certs accounts chains
chown dehydrated:dehydrated certs accounts chains
chmod 750 certs accounts chains

From the dehydrated.git checkout, copy “dehydrated” and “docs/examples/config” to “/opt/dehydrated/” and create the file “/opt/dehydrated/domains.txt” adding one domain per line that you want to generate a unique certificate for.

Edit “/etc/dehydrated/config” and set at least:-

CHALLENGETYPE="dns-01"
CONTACT_EMAIL="you@example.com"
HOOK="/opt/dehydrated/lexicon-hook.sh"

From the lexicon.git checkout, copy “lexicon/examples/dehydrated.default.sh” to “/opt/dehydrated/lexicon-hook.sh”

No changes are required in the lexicon-hook.sh although if you wish to use SSH to transfer new certs and restart services, you can do so in the deploy_cert function within the script which is run anytime a new certificate is generated or renewed and needs deploying.

Set permissions:

chown dehydrated:dehydrated config lexicon-hook.sh dehydrated domains.txt
chmod 750 config lexicon-hook.sh dehydrated domains.txt

All that is missing now is lexicon which the lexicon-hook.sh file invokes. The package py27-dns-lexicon provides this however it failed to install at time of writing so I went with “pip” and a virtual env as follows:

pkg install bash
pkg install py27-pip-9.0.1
pip install virtualenv
virtualenv /opt/dehydrated/env
bash
source /opt/dehydated_env/bin/activate
pip install dns-lexicon
deactivate

Cert Generation

With the above in place, the jail should now be configured and able to run dehydrated and in turn lexicon to generate new domain certificates. Test it out manually:

source /opt/dehydrated/env/bin/activate
/opt/dehydrated/dehydrated –register –accept-terms
PROVIDER=NS1 LEXICON_NS1_USERNAME=user LEXICON_NS1_TOKEN=token /opt/dehydrated/dehydrated -c
deactivate

Replacing “user” with your NS1 account name and “token” with the NS1 generated API Token.

Assuming all goes well, the renewal can be automated by creating a /opt/dehydrated/check-cert.sh script (owner root, group root, permission 700) and CRON scheduling it run to run daily at a random time.

#!/usr/local/bin/bash
# Assumes virtualenv has been setup under "env" in the dehydrated root.

# Lexicon auth
export PROVIDER=nsone
export LEXICON_NSONE_USERNAME=USERNAME
export LEXICON_NSONE_TOKEN=API_TOKEN

# Ensure dehydrated runs in virtualenv
su -m dehydrated -c 'bash -c "source /opt/dehydrated/env/bin/activate && /opt/dehydrated/dehydrated -c"'

Additional Jails

With certificate generation now working, it’s time to turn our attention to all the jails and services that need to make use of these certificates.

As all the devices I wish to use certificates with are running as jails within freenas, I went for a slightly hackier approach to deploying certificates (as opposed to modifying lexicon-hook.sh, although freenas is an exception, see below). I chose to mount the relevant /mnt/tank/services/dehydrated/certs/<some_domain>/ directory in each jail that needed it (read-only) under /opt/dehydrated_certs/. Note: each jail should only have the domain sub-directories mounted for the certs it actually needs.

I then scheduled a script to run on each jail once per week that copies the privkey.pem and fullchain.pem out of the /opt/dehydrated_certs/ directory, makes any formatting changes if needed and saves it to /srv/www/ssl or where the service expects to find its ssl certs, followed by reloading the service (or restarting if reload is not supported).

Each jail should have a “dehydrated” user adding again with nologin allowed and the same UID as the freenas user. This is just to ensure the mounted certs directory doesn’t end up accessable to a random user who happened to get the same UID in the jail.

Example reload scripts

# Dokuwiki running on lighttpd
#!/bin/sh

# Copy certs
cat /opt/dehydrated_certs/privkey.pem /opt/dehydrated_certs/cert.pem > /srv/www/ssl/dokuwiki.example.com_cert.pem
cp /opt/dehydrated_certs/fullchain.pem /srv/www/ssl/dokuwiki.example.com\_chain.pem
chown www:www /srv/www/ssl/dokuwiki.example.com*.pem

# Assume new cert and renew just in case
service lighttpd reload

where /mnt/tank/services/dehydrated/certs/dokuwiki.example.com/ is mounted as /opt/dehydrated_certs/

Emby refresh sript:

# Emby
#!/bin/sh

# Generate pfx
openssl pkcs12 -export -in /opt/dehydrated_certs/fullchain.pem -inkey /opt/dehydrated_certs/privkey.pem -out /srv/ssl/emby.example.com.pfx -passout pass:

chown emby:emby /srv/ssl/emby.example.com.pfx

# Assume new cert and renew just in case
service emby-server restart

Rather than a (hacky) refresh-cert.sh in each jail, you could use lexicon-hook.sh in the dehydrated jail which makes a deploy_cert callback anytime a cert is renewed. In there you can manipulate the cert files, use SSH to deploy to a remote server and reload services. This has the advantage that services are only reloaded/restarted when a new certificate is transfered.

SSH is quite a flexible option but be sure to consider the security implications should the dehydrated jail be compromised. You don’t want an easy cascade of compromises. Consider SFTP Chroot and “command” restrictions. Ansible is another alternative.

Final FreeNAS Config

I’ve not yet managed to get the FreeNAS GUI to automatically use the Let’s Encrypt certs as I ran into what appears to be a bug in the v1.0 certificate REST API on 9.10.x (As of FreeNAS 11.2 this now works). The below script is called via lexicon-hook.sh with the following in deploy_cert()

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

    echo "deploy_cert called: ${DOMAIN}, ${KEYFILE}, ${CERTFILE}, ${FULLCHAINFILE}, ${CHAINFILE}"

    # 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).

    # local jails will copy certs from mounted/shared storage on a
    # regular schedule and reload service. Remote servers will need
    # handling here with ssh call (switch on DOMAIN) as will any
    # jail where reload isn't supported and regular restarts would
    # be a problem.
    echo "Deploy cert for ${DOMAIN}"
    if [ "yourdomain.example.com" == ${DOMAIN} ] ; then
        echo "Updating freenas via API calls. Manual service restart will be required"
        python /opt/dehydrated/freenas-cert-update.py
    fi
}

I’ve filed a bug report for the API call on 9.10.x as well as a feature request for 11.x to add an API call to allow setting the active certificate. [Update: Fixed in FreeNAS 11.2]

#
# Imports certificate and private key into freenas
# Ensure after the first run you select the certificate in the GUI
# to activate it.
#

import sys
import json
import requests
from datetime import datetime

#
# Configuration
#

PRIVATEKEY_PATH="/opt/dehydrated/certs/yourdomain.example.com/privkey.pem"
FULLCHAIN_PATH="/opt/dehydrated/certs/yourdomain.example.com/fullchain.pem"
USER="root"
PASSWORD="YOURFREENASPASS"
DOMAIN_NAME="yourdomain.example.com"
PROTOCOL="https://"

now = datetime.now()
cert = "letsencrypt-%s-%s-%s" %(now.year, now.strftime('%m'), now.strftime('%d'))

print ("Uploading new cert as: " + cert )

#
# Cert Upload
#

# Load cert/key
with open(PRIVATEKEY_PATH, 'r') as file:
    priv_key = file.read()
with open(FULLCHAIN_PATH, 'r') as file:
    full_chain = file.read()

# Update or create certificate
# REVIEW: cert_serial?
r = requests.post(
    PROTOCOL + DOMAIN_NAME + '/api/v1.0/system/certificate/import/',
    auth=(USER, PASSWORD),
    headers={'Content-Type': 'application/json'},
    data=json.dumps({
      "cert_name": cert,
      "cert_certificate": full_chain,
      "cert_privatekey": priv_key
    }),
)

if r.status_code == 201:
  print ("Certificate import successful")
else:
  print ("Error importing certificate!")
  print (r)
  sys.exit(1)

# Download certificate
# set limit to 0 to disable paging in the event of many certificateste list
limit = {'limit': 0}
r = requests.get(
  PROTOCOL + DOMAIN_NAME + '/api/v1.0/system/certificate/',
  params=limit,
  auth=(USER, PASSWORD))

if r.status_code == 200:
  print ("Certificate list successful")
else:
  print ("Error listing certificates!")
  print (r)
  sys.exit(1)

# Parse certificate list to find the id that matches our cert name
cert_list = r.json()

for index in range(100):
  cert_data = cert_list[index]
  if cert_data['cert_name'] == cert:
    cert_id = cert_data['id']
    break

# Set our cert as active
r = requests.put(
  PROTOCOL + DOMAIN_NAME + '/api/v1.0/system/settings/',
  auth=(USER, PASSWORD),
  headers={'Content-Type': 'application/json'},
  data=json.dumps({
  "stg_guicertificate": cert_id,
  }),
)

if r.status_code == 200:
  print ("Setting active certificate successful")
else:
  print ("Error setting active certificate!")
  print (r)
sys.exit(1)

# Reload nginx with new cert (Only possible when this script is running on freenas
# itself, if using a jail or another machine, you need to use the API call but that
# does not work in 11.1 or prior)
try:
  r = requests.post(
    PROTOCOL + DOMAIN_NAME + '/api/v1.0/system/settings/restart-httpd-all/',
    auth=(USER, PASSWORD),
  )
except requests.exceptions.ConnectionError:
  pass # This is expected when restarting the web server

Update: The above script has been edited to work with FreeNAS 11.2 and the extra API calls made available to activate the uploaded cert. I’ve also incorporated a few changes danb35 from the FreeNAS forums made to the original script.