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). 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 (apparently working on 11.0). The eventual plan is to setup a CRON task via the web GUI to run the python script /mnt/tank/services/dehydrated/freenas-cert-update.py which will import the FreeNAS certificate and private key via the REST API and activate it just as you would do manually via the GUI.

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.

The script below is a test only.

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

#
# Configuration
# 

PRIVATEKEY_PATH="/mnt/tank/services/dehydrated/certs/freenas.example.com/privkey.pem"
FULLCHAIN_PATH="/mnt/tank/services/dehydrated/certs/freenas.example.com/fullchain.pem"
USER="root"
PASSWORD="YOUR_PASSWORD"
DOMAIN_NAME="freenas.example.com"

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

# Import Cert
r = requests.post(
    'https://' + DOMAIN_NAME + '/api/v1.0/system/certificate/import/',
    auth=(USER, PASSWORD),
    headers={'Content-Type': 'application/json'},
    data=json.dumps({
      "cert_name": "letsencrypt",
      "cert_certificate": full_chain,
      "cert_privatekey": priv_key,
      "cert_serial": 1
    }),
)

# TODO: Check response 201 ok and activate cert
print r

Hope someone finds the above useful. Once I’ve upgraded to whichever version of FreeNAS 11 gets the activate cert API addition, I’ll amend the above script.