LetsEncrypt with DNS-01 validation for Unifi

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:

  1. Created a new, unprivileged user on the host:
    sudo adduser dehydrated
  2. Created the directory to store the certificates. I chose to use the default /etc/letsencrypt folder
    sudo mkdir /etc/letsencrypt
  3. I granted the dehydrated user full access to this folder:
    sudo chown dehydrated:dehydrated /etc/letsencrypt
  4. installed the dependencies which are in the repo:
    sudo apt-get install --no-install-recommends jq sed findutils s-nail
  5. installed the cli53 dependency which is not in the repo by following the instructions on the git readme.md file
  6. logged in as the dehydrated user:
    su - dehydrated
  7. fetched dehydrated and made it executable:
    wget https://raw.githubusercontent.com/lukas2511/dehydrated/master/dehydrated
    chmod +x dehydrated
  8. 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
  9. 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/*"
            }
        ]
    }
  10. 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.
  11. 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
  12. 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: )
    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"
  13. 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
  14. 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:
    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 cleanup
  15. 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-date -I;
    cp /etc/letsencrypt/myhost.mydomain.com/keystore /var/lib/unifi/keystore;
    service unifi restart;
  16. 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
  17. returned to my previous shell:
    exit
  18. 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