Securing Pi-Hole Admin interfaces on the Internet
A while ago I wrote an article on how to access your Pi-hole interface from the internet securely. At the time of this writing that article is still accurate and I still use it but in my ever-expanding pursuit of knowledge I asked myself; "can I do this even more securely?". The ultimate answer is yes by establishing a zero-trust network.
Feel free to skip this section as it's just a rant from me. I really hate the term "zero-trust network" because it simply makes no sense. There's no actual universally agreed upon definition of what a Zero-Trust network actually is. In general, I've found that Zero-Trust networks require a few things but mainly revolve around using some sort of identity management service to control who can access resources and what resources they can access. An HR employee might need access to the payroll system but won't need access to a Splunk instance to pull server logs.
Traditional VPNs don't offer this sort of authentication and that leaves the it on the servers to authenticate each individual user (which they should be doing anyways).
I could rant for a while about how the whole name Zero-Trust Network makes no sense in general since you're establishing trust through identify management and then granting access to resources based on that trust.
BLUF: This set up will cost money but not a ton; about $84 a year. Here's the list of things you'll need a subsequent cost associated with them. This is also assuming that you already have a Raspberry Pi or some other Linux server with Pi-Hole installed.
A custom domain name: $12/year
Cloudflare account: Free
Nginx Reverse Proxy (optional, but also free).
The domain name is absolutely essential to this set up since we will be using Cloudflare to proxy and provide the zero trust for our network. Cloudflare requires you to control the root domain (in my example: gravitywall.net). In my previous guide we set up a DuckDNS docker container to create a DynamicDNS link back to our server; that container will still be useful but Cloudflare doesn't work with subdomains under DuckDNS (or any other DynDNS service).
I bought my domain from domains.google for $12 a year. You should note when you're doing this that .com domains are normally much more expensive and $12 is the cheapest I've found. You can buy your domain from whomever you want, I chose Google for easy integration with GSuite.
Getting a Cloudflare account is easy and free if you have a medium sized website. I'd imagine if you're just using this for access to your own resources you aren't going to hit Cloudflare's hard limit cap of 100,000 connections a month. Once you get your account Cloudflare will walk you through the steps of changing your domains DNS servers and adding websites.
This is where things can get expensive if want/need a lot of users. Cloudflare access costs $3 per user per month after 5 users. With less than 5 users you're paying $3 month. There's also a premium plan which gives you integration with things like GSuite SAML but that's $6 per user per month. I'll dive more into setting up Cloudflare Access in the actual tutorial.
Cloudflare made Access free for up to 50 users.
In the original post I used Letsencrypt docker with NGINX to create a reverse proxy with a free TLS cert to enable HTTPs coms to the Pi-Hole's admin interface. This time I'm not using it for that, in fact this set up makes getting a TLS cert from Letsencrypt bot more challenging because Cloudflare access will block the http challenge we used the first time. If the only thing you plan to host is the Pi-Hole admin interface than it's not necessary to use a Letsencrypt cert as Cloudflare will provide a TLS cert for your domain already; however, if you plan to host multiple resources then having that Letsencrypt container with NGINX reverse proxy is useful. I'll discuss this later.
Now that we have all of our requirements managed let's talk about the set up. In your Cloudflare dashboard you'll need to make a new record for pihole.yourdomain.tld (or whatever you want). For me it's pihole.gravitywall.net. Use the public IP address of your Pi-Hole server; I'd imagine for most of us that is going to be our home IP.
If your home IP address is dynamic, like most of our probably are, then you will probably want to set up add ddcLient script on your Pi-Hole so that it automatically updates Cloudflare with the public IP every 10 minutes. Unfortunately, standard ddclient that you can install using apt has some issues with Cloudflare, but thankfully someone came up with a working fork of it. You can read that here.
You'll also want to set up a cron job to run ddclient everyday so that it updates if your server power cycles.
If you have a relatively stable IP address (some of the major ISPs like Verizon and AT&T don't rotate IPs frequently) then this step is less important; however, this is why we keep the DuckDNS container. If your ISP does change your IP you can quickly ping your DuckDNS domain, grab your public IP, and update Cloudflare. The ddclient is easier, but hey it's your choice.
Once you put an IP address to the domain it will be almost immediately reachable, that means for a short time your domain will be without the Zero Trust authentication. Head on over to the Access tab and hit the manage access button. Set up the payment information.
The next step is to configure your identity providers. By default, it will only have email authentication where each user gets emailed a six-digit code; however, you can set up others and Cloudflare will give you all the instructions you need to set up each one. The email authentication works fine but it's slow and I don't like slow, so I use Google and Facebook to authenticate users.
The next step is to set up an access policy. These are pretty straight forward but one thing that Cloudflare doesn't mention is that methods inside of one access policy are ANDed together, where multiple access policies are ORed together. This took me a little bit of time to understand as I had multiple conditions that couldn't possibly be met together in one policy. My policies looked like "email must end in @gravitywall.net" AND "email must be firstname.lastname@example.org". It wasn't possible to meet both of those conditions simultaneously so Access wouldn't authenticate anyone. Creating multiple access policies ORs those statements together so that anyone with an @gravitywall.net email OR that one specific user can access this resource.
Create you access policy as its required for this to work. Cloudflare will not allow anyone access to the resources without them matching an access policy. That means if you're using say Github to authenticate you then the email you use for your GitHub account must be added to an access policy; otherwise Cloudflare won't let you in.
Protecting your Public IP
This next section gets a little bit trickier as it's very dependent on how you have your network set up, ie if you're using Docker or not. Let's first explain the problem.
Cloudflare access is great for establishing an identify management process to allow access but what's to stop someone from finding out your public IP and just entering that into the browser directly followed up with /admin? If you're only hosting Pi-Hole then this will still bring you to your Pi-Hole admin interface, completely bypassing the Zero-Trust part. This obviously isn't what we want. Adding an NGINX reverse proxy here can help as it will proxy the connection to the right service based on URL, so entering your IP address will just give you the NGINX landing page.
However, this is still not the ideal solution because we don't want the entire internet to be able to directly touch our server. To solve this, we are going to create firewall rules to block access to everyone except the Cloudflare IP ranges.
NOTE: Cloudflare lists all their IP ranges here. I will only provide context for one and the rest will need to be done yourself.
There are three main ways to do this and they largely depend on how you have your network set up.
The ideal way to set this up would be to apply the blocks in your router firewall; however, this requires a more advanced router than a traditional SOHO router. You'll want to block all inbound from everything and then allow only the Cloudflare IPs.
If you can't modify your router firewall like this than your next best option is to use UFW on your server directly (this won't work if you are using a NGINX proxy in Docker. I'll explain why in a moment). To start we'll install UFW to set up the firewall, that is its point after all.
Now I want to make sure you understand one thing before we begin. UFW has a default setting of deny all inbound, this means that it can impact your SSH connection. Modifying UFW is best done with direct access to the local machine.
One of the first steps you'll want to make is to permit access from your subnet as enabling UFW will block DNS and break Pi-hole.
After you'll want to permit cloudflare IPs
Repeat this for every IP in the list mentioned above. You can script this by copying the list in a text file and running a for loop against it
Once you've put in all the allow commands the next step is to enable the firewall.
You can check your ufw status by running
Now try your public IP address again and you shouldn't be able to access it anymore.
Alright, so here's the issue. Docker sets up its own custom rules to IPTables so that it is always accessible. This means that you need to add your firewall rules directly to the Docker IPTables chain. If you've worked with IPTables before you'll know that it's not a particularly enjoyable experience. The Docker forums actually have a pretty decent guide on this so I'm not going to repeat that here. Go read that guide or the author's personal blog on the subject.
You should now have a zero-trust network set up for your Pi-Hole admin interface and can access your pihole from the internet securely.
EDIT: Since I wrote this article CloudFlare made Access free for up to 50 users per month.