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 dehydrated
- Created the directory to store the certificates. I chose to use the default /etc/letsencrypt folder
sudo mkdir /etc/letsencrypt
- I granted the dehydrated user full access to this folder:
sudo chown dehydrated:dehydrated /etc/letsencrypt
- installed the dependencies which are in the repo:
sudo apt-get install --no-install-recommends jq sed findutils s-nail
- installed 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 - dehydrated
- fetched dehydrated and made it executable:
wget https://raw.githubusercontent.com/lukas2511/dehydrated/master/dehydrated chmod +x dehydrated
- fetched 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.sh
- Created 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/credentials
The file looks like this (obviously, put your own data in there):
[default] aws_access_key_id = AKIAIJFAKGII4MDS3KHA aws_secret_access_key = Q+XoOGa5J3AS39as593Ds1f5F91zRy0btkfW
- Created a new config file:
nano ~/config
The 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: ) [email protected] # 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.txt
The file looks like this (obviously, put your own data in there):
myhost.mydomain.com
- Checked 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:
[email protected]:~$ ./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 cleanup
- Created 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>&1
- returned to my previous shell:
exit
- Created cron jobs to install the new cert daily:
sudo crontab -e
I added this line:29 5 * * * /home/dehydrated/updateunificert >/dev/null 2>&1