LetsEncrypt with DNS-01 validation for Unifi
Update 2021-01-08: this is now out of date. See my updated post with a much easier method.
I have a number of Ubiquiti UAPs, and I manage them with the UniFi app, installed on a linode server. Like any publicly hosted server, i want to use a trusted SSL certificate, and for that, I chose LetsEncrypt with DNS-01 validation, as i found a useful helper script by thatsamguy on the UniFi forums. I use AWS Route53 to host the DNS zone.
UniFi doesn’t have built in support for LetsEncrypt, so I put together a simple solution using the DNS-01 validation method. Here’s how i did it:
Created a new, unprivileged user on the host:
sudo adduser dehydratedCreated the directory to store the certificates. I chose to use the default /etc/letsencrypt folder
sudo mkdir /etc/letsencryptI granted the dehydrated user full access to this folder:
sudo chown dehydrated:dehydrated /etc/letsencryptinstalled the dependencies which are in the repo:
sudo apt-get install --no-install-recommends jq sed findutils s-nailinstalled the cli53 dependency which is not in the repo by following the instructions on the git readme.md file
logged in as the dehydrated user:
su - dehydratedfetched dehydrated and made it executable:
wget https://raw.githubusercontent.com/lukas2511/dehydrated/master/dehydrated
chmod +x dehydratedfetched the dehydrated route53 hook script and made it executable:
wget https://raw.githubusercontent.com/whereisaaron/dehydrated-route53-hook-script/master/hook.sh
chmod +x hook.shCreated a new IAM access policy in AWS. I found that the sample policy given with the root 53 hook readme.md didn’t work - here’s the policy that I added:
{
“Version”: “2012-10-17”,
“Statement”: [
{
“Effect”: “Allow”,
“Action”: [
“route53:ListHostedZones”,
“route53:ListHostedZonesByName”,
“route53:ListResourceRecordSets”
],
“Resource”: “*”
},
{
“Effect”: “Allow”,
“Action”: [
“route53:ListResourceRecordSets”,
“route53:ChangeResourceRecordSets”
],
“Resource”: “arn:aws:route53:::hostedzone/*”
},
{
“Effect”: “Allow”,
“Action”: [
“route53:GetChange”
],
“Resource”: “arn:aws:route53:::change/*”
}
]
}Created a new IAM user in AWS and assigned the policy to them (and only this policy), taking a note of the AWS Access Key and AWS Secret Access Key.
Created a new file to store the key:
mkdir ~/.aws
nano ~/.aws/credentialsThe file looks like this (obviously, put your own data in there):[default]
aws_access_key_id = AKIAIJFAKGII4MDS3KHA
aws_secret_access_key = Q+XoOGa5J3AS39as593Ds1f5F91zRy0btkfWCreated a new config file:
nano ~/configThe file looks like this (edited from the sample):########################################################
# This is the main config file for dehydrated #
# #
# This file is looked for in the following locations: #
# $SCRIPTDIR/config (next to this script) #
# /usr/local/etc/dehydrated/config #
# /etc/dehydrated/config #
# ${PWD}/config (in current working-directory) #
# #
# Default values of this config are in comments #
######################################################### Resolve names to addresses of IP version only. (curl)
# supported values: 4, 6
# default:
#IP_VERSION=# Path to certificate authority (default: https://acme-v01.api.letsencrypt.org/directory)
#CA=“https://acme-v01.api.letsencrypt.org/directory"
# CA=“https://acme-staging.api.letsencrypt.org/directory"# Path to certificate authority license terms redirect (default: https://acme-v01.api.letsencrypt.org/terms)
#CA_TERMS=“https://acme-v01.api.letsencrypt.org/terms"
# CA_TERMS=“https://acme-staging.api.letsencrypt.org/terms"# Path to license agreement (default: )
#LICENSE=””# Which challenge should be used? Currently http-01 and dns-01 are supported
CHALLENGETYPE=“dns-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:
#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”# Directory for account keys and registration information
#ACCOUNTDIR="${BASEDIR}/accounts"# Output directory for challenge-tokens to be served by webserver or deployed in HOOK (default: /var/www/dehydrated)
#WELLKNOWN="/var/www/dehydrated"# Default keysize for private keys (default: 4096)
#KEYSIZE=“4096”# Path to openssl config file (default: - tries to figure out system default)
#OPENSSL_CNF=# Extra options passed to the curl binary (default: )
#CURL_OPTS=# 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:
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”# Create an extra private key for rollover (default: no)
#PRIVATE_KEY_ROLLOVER=“no”# Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1
#KEY_ALGO=rsa# E-mail to use during the registration (default: )
CONTACT_EMAIL=myemail@mydomain.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”# Fetch OCSP responses (default: no)
#OCSP_FETCH=“no”# Issuer chain cache directory (default: $BASEDIR/chains)
#CHAINCACHE="${BASEDIR}/chains"# Automatic cleanup (default: no)
AUTO_CLEANUP=“yes”Created a new file with the list of domains to register:
nano ~/domains.txtThe file looks like this (obviously, put your own data in there):myhost.mydomain.comChecked that the certificate registers correctly - note that if you are having trouble, you should enable the “staging” CA / terms file in config while you troubleshoot to avoid hitting letsencrypt limits:
dehydrated@localhost:~$ ./dehydrated –cron –accept-terms –out /etc/letsencrypt
# INFO: Using main config file /home/dehydrated/config
+ Generating account key…
+ Registering account key with ACME server…
Processing myhost.mydomain.com
+ Signing domains…
+ Creating new directory /home/dehydrated/certs/myhost.mydomain.com …
+ Generating private key…
+ Generating signing request…
+ Requesting challenge for myhost.mydomain.com…
Creating challenge record for myhost.mydomain.com in zone cynexia.net
Created record: ‘_acme-challenge.myhost.mydomain.com. 60 IN TXT “cpE1VF_xshMm1IVY1Y66Kk9Zb_7jA2VFkP65WuNgu3Q”’
Waiting for sync……………………………..
Completed
+ Responding to challenge for myhost.mydomain.com…
Deleting challenge record for myhost.mydomain.com from zone mydomain.com
1 record sets deleted
+ Challenge is valid!
+ Requesting certificate…
+ Checking certificate…
+ Done!
+ Creating fullchain.pem…
+ Using cached chain!
+ Done!
+ Running automatic cleanupCreated the helper script, remembering to edit the domain (find/replace) and the certificate path:
nano updateunificert
My file looks like this:
!/bin/bash
PATH=’/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games’;
openssl pkcs12 -export -in /etc/letsencrypt/myhost.mydomain.com/fullchain.pem -inkey /etc/letsencrypt/myhost.mydomain.com/privkey.pem -out /etc/letsencrypt/myhost.mydomain.com/cert_and_key.p12 -name tomcat -CAfile /etc/letsencrypt/myhost.mydomain.com/chain.pem -caname root -password pass:aaa;
rm -f /etc/letsencrypt/myhost.mydomain.com/keystore;
keytool -importkeystore -srcstorepass aaa -deststorepass aircontrolenterprise -destkeypass aircontrolenterprise -srckeystore /etc/letsencrypt/myhost.mydomain.com/cert_and_key.p12 -srcstoretype PKCS12 -alias tomcat -keystore /etc/letsencrypt/myhost.mydomain.com/keystore;
keytool -import -trustcacerts -alias unifi -deststorepass aircontrolenterprise -file /etc/letsencrypt/myhost.mydomain.com/chain.pem -noprompt -keystore /etc/letsencrypt/myhost.mydomain.com/keystore;
mv /var/lib/unifi/keystore /var/lib/unifi/keystore-backup;
cp /etc/letsencrypt/myhost.mydomain.com/keystore /var/lib/unifi/keystore;
service unifi restart;Created cron jobs to trigger the cert update daily:
crontab -e
I added this line:
20 5 * * * /home/dehydrated/dehydrated –cron –accept-terms –out /etc/letsencrypt >/dev/null 2>&1returned to my previous shell:
exitCreated cron jobs to install the new cert daily:
sudo crontab -e
I added this line:
29 5 * * * /home/dehydrated/updateunificert >/dev/null 2>&1