I recently blogged about how I completely overhauled my home network Ubiquiti networking gear. Whilst I was doing that write up it reminded me that I'd been meaning to get rid of the annoying warning you get whenever you try and visit the controller in the browser:

Browsers warn that the certificate used by the Cloud Key out-of-the-box doesn't match the domain you''re accessing.

The browser is doing the right thing here, the certificate provided by the Cloud Key out-of-the-box won't be setup to match the domain or IP address that your Cloud Key is hosted on.

We can fix this by configuring the hostname for the UniFi Controller software and by providing our own certificate for the hostname.

Tooling and Certificate Issuance

My Cloud Key sits on my network and isn't directly exposed to the internet, and I plan to keep it this way. I also haven't paid for a SSL Certificate in probably around 5 years thanks to things like Let's Encrypt and other service providers issues certificates for free, and I plan to use a free certificate for my Cloud Key too.

Whenever a Certificate Authority issues a certificate they have to ensure that you are in control of the domain that you want the certificate for. Projects like Let's Encrypt do this using an ACME (Automatic Certificate Management Environment) challenge to verify that you own the domain.

There are a number of challenge types and the Let's Encrypt documentation does a good job of describing the challenge types and their pros and cons. A HTTP Challenge is possibly the most popular challenge where you end up hosting a file (e.g. http://<YOUR_DOMAIN>/.well-known/acme-challenge/<TOKEN>) on your server to prove that you're in control of the domain.

A HTTP challenge works well when you're server is exposed to the internet. In the case of my Cloud Key, I own the domain that I want to use, but I don't have it exposed to the internet, nor do I want to change that. This is where a DNS Challenge comes in useful.

With a DNS challenge I can upload a DNS record (e.g. _acme-challenge.<YOUR_DOMAIN>) that will prove I have control of the domain. This is ideal for my use case as I can create the DNS record without exposing my Cloud Key to the internet.

Installing Let's Encrypt

Start by SSHing onto your Cloud Key and installing Let's Encrypt CLI tooling via curl https://get.acme.sh | sh:

$ ssh ubnt@192.168.75.149

Linux UniFi-CloudKey-Gen2-Plus 3.18.44-ubnt-qcom #1 SMP Thu Oct 31 08:52:58 UTC 2019 aarch64

Firmware version: v1.1.6
                .--.__
  ______ __ .--(    ) )-.
 |      |  (._____.__.___)_|  |  |__ _____ __ __   _|  |_
 |   ---|  ||  _  |  |  |  _  |    <|  -__|  |  | |_    _|
 |______|__||_____|_____|_____|__|__|_____|___  |   |__|
        (c) 2019 Ubiquiti Networks, Inc.  |_____|

      Welcome to the CloudKey Plus!
Last login: Fri Apr 10 19:34:34 2020 from 192.168.75.7

root@UniFi-CloudKey-Gen2-Plus:~# curl https://get.acme.sh | sh
[Fri Apr 10 19:39:02 BST 2020] Installing from online archive.
[Fri Apr 10 19:39:02 BST 2020] Downloading https://github.com/acmesh-official/acme.sh/archive/master.tar.gz
[Fri Apr 10 19:39:03 BST 2020] Extracting master.tar.gz
[Fri Apr 10 19:39:03 BST 2020] It is recommended to install socat first.
[Fri Apr 10 19:39:03 BST 2020] We use socat for standalone server if you use standalone mode.
[Fri Apr 10 19:39:03 BST 2020] If you don't use standalone mode, just ignore this warning.
[Fri Apr 10 19:39:03 BST 2020] Installing to /root/.acme.sh
[Fri Apr 10 19:39:03 BST 2020] Installed to /root/.acme.sh/acme.sh
[Fri Apr 10 19:39:03 BST 2020] Installing alias to '/root/.bashrc'
[Fri Apr 10 19:39:03 BST 2020] OK, Close and reopen your terminal to start using acme.sh
[Fri Apr 10 19:39:03 BST 2020] Installing cron job
no crontab for root
no crontab for root
[Fri Apr 10 19:39:03 BST 2020] Good, bash is found, so change the shebang to use bash as preferred.
[Fri Apr 10 19:39:05 BST 2020] OK
[Fri Apr 10 19:39:05 BST 2020] Install success!
root@UniFi-CloudKey-Gen2-Plus:~# 

The UniFi software will require our certificate to exist in the Java Keystore that it uses. To do this, we can use a post-hook file to automatically add the certificate issues by Let's Encrypt into the Java Keystore file. Gerd Naschenweng provides an implementation that works out of the box in his blog post.

Create the following /root/.acme.sh/cloudkey-renew-hook.sh file

#!/bin/bash
# Renew-hook for ACME / Let's encrypt
echo "** Configuring new Let's Encrypt certs"
cd /etc/ssl/private
rm -f /etc/ssl/private/cert.tar /etc/ssl/private/unifi.keystore.jks /etc/ssl/private/ssl-cert-snakeoil.key /etc/ssl/private/fullchain.pem

openssl pkcs12 -export -in /etc/ssl/private/cloudkey.crt -inkey /etc/ssl/private/cloudkey.key -out /etc/ssl/private/cloudkey.p12 -name unifi -password pass:aircontrolenterprise

keytool -importkeystore -deststorepass aircontrolenterprise -destkeypass aircontrolenterprise -destkeystore /usr/lib/unifi/data/keystore -srckeystore /etc/ssl/private/cloudkey.p12 -srcstoretype PKCS12 -srcstorepass aircontrolenterprise -alias unifi

rm -f /etc/ssl/private/cloudkey.p12
tar -cvf cert.tar *
chown root:ssl-cert /etc/ssl/private/*
chmod 640 /etc/ssl/private/*

echo "** Testing Nginx and restarting"
/usr/sbin/nginx -t
/etc/init.d/nginx restart ; /etc/init.d/unifi restart
/root/.acme.sh/cloudkey-renew-hook.sh

I'm planning on using a DNS Challenge so that Let's Encrypt can verify that I control the domain, and continue to that moving forward as the certificate needs renewing. The DNS for my domain is managed via Cloudflare which is supported by Let's Encrypt. The ACME DNS API will need an API token in order to update DNS settings.

Create a token via the Cloudflare Dashboard:

Use the Edit zone DNS template:

I chose to restrict the Zones that the API token can access by explicitly including the relevant zone under Zone Resources:

Go back to the CloudKey SSH session and modify the ~/.bashrc file to set the following environment variables to the relevant values for your API token and zone:

export CF_Token="xxxxx"
export CF_Account_ID="xxxxx"
export CF_Zone_ID="xxxxx"

I put all three of these environment variables before the line

. "/root/.acme.sh/acme.sh.env"

Once you've done this restart your SSH session so that the environment variables are picked up, and to pick up the bash alias that the acme installation created.

We can now issue our certificate I've set my -d argument to unifi.home.jamesridgway.co.uk, you will want to change this for the hostname that you want to use:

root@UniFi-CloudKey-Gen2-Plus:~# acme.sh --force --issue --dns dns_cf -d unifi.home.jamesridgway.co.uk --pre-hook "touch /etc/ssl/private/cert.tar; tar -zcvf /root/.acme.sh/CloudKeySSL_`date +%Y-%m-%d_%H.%M.%S`.tgz /etc/ssl/private/*" --fullchainpath /etc/ssl/private/cloudkey.crt --keypath /etc/ssl/private/cloudkey.key --reloadcmd "sh /root/.acme.sh/cloudkey-renew-hook.sh"

This should result in an output that looks a like this:

[Fri Apr 10 20:00:21 BST 2020] Run pre hook:'touch /etc/ssl/private/cert.tar; tar -zcvf /root/.acme.sh/CloudKeySSL_2020-04-10_20.00.20.tgz /etc/ssl/private/*'
tar: Removing leading `/' from member names
/etc/ssl/private/cert.tar
/etc/ssl/private/cloudkey.crt
/etc/ssl/private/cloudkey.key
/etc/ssl/private/unifi.keystore.jks
/etc/ssl/private/unifi.keystore.jks.md5
[Fri Apr 10 20:00:22 BST 2020] Single domain='unifi.home.jamesridgway.co.uk'
[Fri Apr 10 20:00:22 BST 2020] Getting domain auth token for each domain
[Fri Apr 10 20:00:24 BST 2020] Getting webroot for domain='unifi.home.jamesridgway.co.uk'
[Fri Apr 10 20:00:24 BST 2020] Adding txt value: UNVIZ_keOTph3IZk85-bk2a0Eq8ZjdbmWoAZwcWqmsA for domain:  _acme-challenge.unifi.home.jamesridgway.co.uk
[Fri Apr 10 20:00:25 BST 2020] Adding record
[Fri Apr 10 20:00:26 BST 2020] Added, OK
[Fri Apr 10 20:00:26 BST 2020] The txt record is added: Success.
[Fri Apr 10 20:00:26 BST 2020] Let's check each dns records now. Sleep 20 seconds first.
[Fri Apr 10 20:00:47 BST 2020] Checking unifi.home.jamesridgway.co.uk for _acme-challenge.unifi.home.jamesridgway.co.uk
[Fri Apr 10 20:00:48 BST 2020] Domain unifi.home.jamesridgway.co.uk '_acme-challenge.unifi.home.jamesridgway.co.uk' success.
[Fri Apr 10 20:00:48 BST 2020] All success, let's return
[Fri Apr 10 20:00:48 BST 2020] Verifying: unifi.home.jamesridgway.co.uk
[Fri Apr 10 20:00:52 BST 2020] Success
[Fri Apr 10 20:00:52 BST 2020] Removing DNS records.
[Fri Apr 10 20:00:52 BST 2020] Removing txt: UNVIZ_keOTph3IZk85-bk2a0Eq8ZjdbmWoAZwcWqmsA for domain: _acme-challenge.unifi.home.jamesridgway.co.uk
[Fri Apr 10 20:00:53 BST 2020] Removed: Success
[Fri Apr 10 20:00:53 BST 2020] Verify finished, start to sign.
[Fri Apr 10 20:00:53 BST 2020] Lets finalize the order, Le_OrderFinalize: https://acme-v02.api.letsencrypt.org/acme/finalize/83067480/2957503515
[Fri Apr 10 20:00:54 BST 2020] Download cert, Le_LinkCert: https://acme-v02.api.letsencrypt.org/acme/cert/5854b85854b85854b85854b85854b85854b8
[Fri Apr 10 20:00:55 BST 2020] Cert success.
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
[Fri Apr 10 20:00:55 BST 2020] Your cert is in  /root/.acme.sh/unifi.home.jamesridgway.co.uk/unifi.home.jamesridgway.co.uk.cer
[Fri Apr 10 20:00:55 BST 2020] Your cert key is in  /root/.acme.sh/unifi.home.jamesridgway.co.uk/unifi.home.jamesridgway.co.uk.key
[Fri Apr 10 20:00:55 BST 2020] The intermediate CA cert is in  /root/.acme.sh/unifi.home.jamesridgway.co.uk/ca.cer
[Fri Apr 10 20:00:55 BST 2020] And the full chain certs is there:  /root/.acme.sh/unifi.home.jamesridgway.co.uk/fullchain.cer
[Fri Apr 10 20:00:56 BST 2020] Installing key to:/etc/ssl/private/cloudkey.key
[Fri Apr 10 20:00:56 BST 2020] Installing full chain to:/etc/ssl/private/cloudkey.crt
[Fri Apr 10 20:00:56 BST 2020] Run reload cmd: sh /root/.acme.sh/cloudkey-renew-hook.sh
** Configuring new Let's Encrypt certs
Importing keystore /etc/ssl/private/cloudkey.p12 to /usr/lib/unifi/data/keystore...

Warning:
The JKS keystore uses a proprietary format. It is recommended to migrate to PKCS12 which is an industry standard format using "keytool -importkeystore -srckeystore /usr/lib/unifi/data/keystore -destkeystore /usr/lib/unifi/data/keystore -deststoretype pkcs12".
cloudkey.crt
cloudkey.key
unifi.keystore.jks
unifi.keystore.jks.md5
** Testing Nginx and restarting
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Restarting nginx (via systemctl): nginx.service.
[Fri Apr 10 20:02:06 BST 2020] Reload success

If you unpick the output you will notice that Let's Encrypt has issued the certificate as normal, before invoking the cloudkey-renew-hook.sh that we created to update the Java Kesytore.

Login to the UniFi Controller, and under Settings > Controller Settings > Advanced update the Controller Hostname/IP:

UniFi Controller: Settings > Controller Settings > Advanced

Finally, add the following entry to your crontab file to ensure that the certificate is renewed automatically:

0 0 * * * /root/.acme.sh/acme.sh --renew --apache --renew-hook /root/.acme.sh/cloudkey-renew-hook.sh -d <YOUR_DOMAIN>

Done!

That's it, we're done!

I found that the change applied straight away for UniFi Controller. I also use UniFi Protect for my cameras, which didn't appear to work straight away, but after a reboot of the Cloud Key I had UniFi Protect and UniFi Controller both working against my Let's Encrypt certificate and new hostname.