When I setup Home Assistant last year one of my niggles was that it wanted an entire subdomain, rather than being able to live under a subdirectory. I had a desire to stick various things behind a single SSL host on my home network (my UniFi controller is the other main one), rather than having to mess about with either SSL proxies in every container running a service, or a bunch of separate host names (in particular one for the backend and one for the SSL certificate, for each service) in order to proxy in a single host.

I’ve recently done some reorganisation of my network, including building a new house server (which I’ll get round to posting about eventually) and decided to rethink the whole SSL access thing. As a starting point I had:

  • Services living in their own containers
  • Another container already running Apache, with SSL enabled + a valid external Let’s Encrypt certificate

And I wanted:

  • SSL access to various services on the local network
  • Not to have to run multiple copies of Apache (or any other TLS proxy)
  • Valid SSL certs that would validate correctly on browsers without kludges
  • Not to have to have things like hass-host as the front end name and hass-backend-host as the actual container name.

It dawned on me that all access to the services was already being directed through the server itself, so there was a natural redirection point. I hatched a plan to do a port level redirect there, sending all HTTPS traffic to the service containers to the container running Apache. It would then be possible to limit access to the services (e.g. port 8123 for Home Assistant) to the Apache host, tightening up access, and the actual SSL certificate would have the service name in it.

First step was to figure out how to do the appropriate redirection. I was reasonably sure this would involve some sort of DNAT in iptables, but I couldn’t find a clear indication that it was possible (there was a lot of discussion about how you also ended up needing SNAT, and I needed multiple redirections to 443 on the Apache container, so that wasn’t going to fly). Having now solved the problem I think iptables could have done it just fine, but I ended up being steered down the nftables route. This is long overdue; it’s been available since Linux 3.13 but lacking a good reason to move beyond iptables I hadn’t yet done so (in the same way I clung to ipfwadm and ipchains until I had to move).

There’s a neat tool, iptables-restore-translate, which can take the output of iptables-save and provide a simple translation to nftables. That was a good start, but what was neater was moving to the inet filter instead of ip which then mean I could write one set of rules which applied to both IPv4 and IPv6 services. No need for rule duplication! The ability to write a single configuration file was nicer than the sh script I had to configure iptables as well. I expect to be able to write a cleaner set of rules as I learn more, and although it’s not relevant for the traffic levels I’m shifting I understand the rule parsing is generally more efficient if written properly.Finally there’s an nftables systemd service in Debian, so systemctl enable nftables turned on processing of /etc/nftables.conf on restart rather than futzing with a pre-up in /etc/network/interfaces.

With all the existing config moved over the actual redirection was easy. I added the following block to the end of nftables.conf (I had no NAT previously in place), which redirects HTTPS traffic directed at 192.168.2.3 towards 192.168.2.2 instead.

nftables dnat configuration
table ip nat {
        chain prerouting {
                type nat hook prerouting priority 0
                # Redirect incoming HTTPS to Home Assistant to Apache proxy
                iif "enp24s0" ip daddr 192.168.2.3 tcp dport https \
                        dnat 192.168.2.2
        }
        chain postrouting {
                type nat hook postrouting priority 100
        }
}

I think the key here is I can guarantee that any traffic coming back from the Apache proxy is going to pass through the host doing the DNAT; each container has a point-to-point link configured rather than living on a network bridge. If there was a possibility traffic from the proxy could go direct to the requesting host (e.g. they were on a shared LAN) then you’d need to do SNAT as well so the proxy would return the traffic to the NAT host which would then redirect to the requesting host.

Apache was then configured as a reverse proxy, with my actual config ending up as follows. For now I’ve restricted access to within my house; I’m still weighing up the pros and cons of exposing access externally without the need for a tunnel. The domain I used on my internal network is a proper registered thing, so although I don’t expose any IP addresses externally I’m able to use Mythic Beasts’ DNS validation instructions and have a valid cert.

Apache proxy config for Home Assistant
<VirtualHost *:443>
        ServerName hass-host

        ProxyPreserveHost On
        ProxyRequests off
        RewriteEngine on

        # Anything under /local/ we serve, otherwise proxy to Home Assistant
        RewriteCond %{REQUEST_URI} '/local/.*'
        RewriteRule .* - [L]
        RewriteCond %{HTTP:Upgrade} =websocket [NC]
        RewriteRule /(.*) ws://hass-host:8123/$1 [P,L]
        ProxyPassReverse /api/websocket ws://hass-host:8123/api/websocket
        RewriteCond %{HTTP:Upgrade} !=websocket [NC]
        RewriteRule /(.*) http://hass-host:8123/$1 [P,L]
        ProxyPassReverse / http://hass-host:8123/

        SSLEngine on
        SSLCertificateFile /etc/ssl/le.crt
        SSLCertificateKeyFile /etc/ssl/private/le.key
        SSLCertificateChainFile /etc/ssl/lets-encrypt-x3-cross-signed.crt

        # Static files can be hosted here instead of via Home Assistant
        Alias /local/ /srv/www/hass-host/
        <Directory /srv/www/hass-host/>
                Options -Indexes
        </Directory>

        # Only allow access from inside the house
        ErrorDocument 403 "Not for you."
        <Location />
                Order Deny,Allow
                Deny from all
                Allow from 192.168.1.0/24
        </Location>
</VirtualHost>

I’ve done the same for my UniFi controller; the DNAT works exactly the same, while the Apache reverse proxy config is slightly different - a change in some of the paths and config to ignore the fact there’s no valid SSL cert on the controller interface.

Apache proxy config for Unifi Controller
<VirtualHost *:443>
	ServerName unifi-host

	ProxyPreserveHost On
	ProxyRequests off

	SSLProxyEngine on
	SSLProxyVerify off
	SSLProxyCheckPeerCN off
	SSLProxyCheckPeerName off
	SSLProxyCheckPeerExpire off

	AllowEncodedSlashes NoDecode
	ProxyPass /wss/ wss://unifi-host:8443/wss/
	ProxyPassReverse /wss/ wss://unifi-host:8443/wss/
	ProxyPass / https://unifi-host:8443/
	ProxyPassReverse / https://unifi-host:8443/

	SSLEngine on
	SSLCertificateFile /etc/ssl/le.crt
	SSLCertificateKeyFile /etc/ssl/private/le.key
	SSLCertificateChainFile /etc/ssl/lets-encrypt-x3-cross-signed.crt

	# Only allow access from inside the house
	ErrorDocument 403 "Not for you."
	<Location />
		Order Deny,Allow
		Deny from all
		Allow from 192.168.1.0/24
	</Location>
</VirtualHost>

(worth pointing out that one of my other Home Assistant niggles has also been fixed - there’s now the ability to setup multiple users and separate out API access to OAuth, rather than a single password providing full access. It still needs more work in terms of ACLs for users, but that’s a bigger piece of work.)