The steps I took to secure my Pi-Hole instance on the cloud
Since I decided to sell my old Dell Optiplex that I used to self host things, the only service that I kept hosting at home was a Pi-Hole instance on an old phone.
It works, sort of.. Like it worked as expected after I found this project, but even for something as well optimized and made to run on a Raspberry Pi, it was horrendously slow. But it was kind of ok, not very reliable or fast, but it got the job done at the end of the day..
But there’s a little problem with hosting things at home: when you’re not at home. I know that something like ZeroTier could solve this, especially since the mobile client has the option to set custom DNS addresses. I was just not brave enough to create a bomb, since the phone could barely run the Pi-Hole instance, adding something like that could potentially create a fire hazard.
So I decided to host it in the cloud, solving both speed and external access problems. At this point I was well aware that doing this was a terrible idea since your instance could be a public resolver, but with DNSSEC and rate limiting I thought it was okay.
Then, very quickly, the thing that ruins the internet began to show up: bots. Some were just friendly security research bots that just ping the server, others were not clear about their purpose and multiple instances of them kept pinging, and then there were the Chinese ones that actually pissed me off a bit.
It’s not that I want to generalize, but every single one of the annoying bots had a Chinese IP address. The majority made a bunch of queries from a handful of different IP addresses, quite a few from the same CIDR.
Now that I think about it, one of them had a kind of natural use, as if a person was actually using it, which makes me think if someone was actually using it…
Anyway, back to the Chinese bots. One day I went to do the usual check on the instance, everything was fine until I looked at the client list. A new bot had made about 500k queries, turning on the query log (which I had turned off since the log file size skyrockets with DNSSEC), it was slamming the service with hundreds of bogus domains, just different enough from each other to not fall into the Pi-Hole cache.
Actually, congratulations to the Pi-Hole development team for making it so optimized. If I hadn’t checked the client list, I wouldn’t have noticed a difference as it handled those hundreds of queries per minute like it was nothing.
And what about now? How do we block all queries from unknown clients without too much work?
The solution was behind a neglected feature that I never thought to use: groups. This way I could create a blocked group that all new clients would fall into, and a whitelisted group with all my clients.
(.*?)
By adding this regex pattern to your domain filter and regex denying it on the Default
group, all queries from all clients will be blocked.
Just make sure you don’t enable it before creating a whitelisted group and adding yourself to it, otherwise you may end up blocking yourself as well. Yes, I’m talking from experience 🥲.
Then all you need to do is create another group where this rule doesn’t apply, add your clients to it, and you’ve successfully created a whitelist.
Now you can sit back and relax, right?
IP addresses change over time and across networks, making manual whitelisting a pain, especially since you are automatically blocked if you are on a different client/IP address.
The laziest solution would be to whitelist your *CIDR, since at least in my experience, just doing that handles any eventual small changes well.
*: Imagine this is your public IP address:
192.123.45.67
, the CIDR would be192.123.45.0/24
.
But what about if you are on mobile data or on public wi-fi? These IPs change all the time and in mobile data, just allowing a CIDR will not do the trick because the whole IP changes frequently.
Keeping track of changing addresses… doesn’t that sound like a task that could be automated? If your answer was yes, it really is! If it was no… umm, isn’t this post extremely boring for you?
So I decided to write a simple Bash script just as a proof of concept, I was then shot 57 times.
Sorry, that meme was the first way I thought of expressing how I felt about looking for documentation for the Pi-Hole API for longer than it should be, only to find out a day later that it lives inside your instance… Why? I don’t know, maybe they wanted to take the Forgejo approach and make it local and interactive, which is good to be able to test things directly in your browser, but Forgejo makes it clean with a link in the footer.
Anyway, finally with the documentation, I decided to code it in Rust. Yes, at this point I gave up on doing it in Bash, since it would actually be easier to implement more complex stuff and make it more portable with Rust.
The steps are actually quite simple:
I’ll do my best to keep the explanation as simple and short as possible. Also don’t mind if the code is bad, it’s been a while since I used Rust, you could say my skills are kind of… you know the joke 😐.
Before getting started, it’s good to organize what variables our program will use. For this use case we need to store:
With that in mind, let’s create a conf.toml
file with the following contents:
api_url = "https://pihole.example.org/api"
app_password = "secret password"
whitelisted_group_name = "whitelisted group name"
And to be able to load this config file into the program, let’s create a simple wrapper function:
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Config {
pub api_url: String,
pub app_password: String,
pub whitelisted_group_name: String,
}
fn config() -> Config {
let conf_file = std::fs::read_to_string("./conf.toml").expect("Failed to read conf.toml");
let parsed: Config =
toml::from_str(&conf_file).expect("Failed to parse conf.toml");
parsed
}
With that properly set up, we can jump right into the first step of the process: authenticate with the Pi-Hole API.
Pi-Hole has a pretty straightforward approach to auth, POST your app password to /api/auth
and get an SID. So, based on our config:
use reqwest;
async fn auth() {
let body = serde_json::json!({
"password": config.app_password
});
let res = reqwest::Client::new()
.post(format!("{}/auth", conf.api_url))
.json(&body)
.send()
.await
.unwrap();
println!("{:?}", res.text().await);
}
Then in your console you should see something like:
{
"session": {
"valid": true,
"sid": ".......",
...
}
...
}
If you didn’t got any errors, you are all good to handle the response and proceed to step 2: fetching the group ID.
In order to add ourselves to the correct group, we need to know which is the correct ID of that group. In reality, it’s quite easy to know, since the Default
group has an ID of 1. But just knowing the answer is no fun, and there’s probably someone with a dozen groups, so let’s use the API for that.
With our SID in hands, we could call /api/groups/[group name]
:
async fn fetch_group_id(sid: String) {
let mut headers = header::HeaderMap::new();
headers.insert("X-FTL-SID", sid.parse().unwrap());
let res = reqwest::Client::new()
.get(format!(
"{}/groups/{}",
conf.api_url, conf.whitelisted_group_name
))
.headers(headers)
.send()
.await
.unwrap();
println!("{:?}", res.text().await);
}
Output:
{
"groups": [
{
"name": "Whitelist",
"id": 2
}
]
...
}
No errors? Awesome! Now to the next step: find your IP address.
Despite the title, we will not use any of these websites for this example. Instead, we will use an instance of traefik/whoami
. Why do this? I just had one running for some testing and since it already has an API, why not?
Actually, since this step might be different for your use case, I’m going to assume that you already have your public IPv4 address, and now you’re just getting the CIDR. This step actually took me a minute before I realized “wait, I just want to put a zero and a forward slash, just split the string bro”.
And after resisting the urge to search “How to split a string in Rust” on StackOverflow, the code is:
let ip_parts: Vec<&str> = ip_addr.split('.').collect();
if ip_parts.len() != 4 {
println!("Sir that's not a valid IPv4 address");
}
let cidr = format!("{}.{}.{}.0/24", ip_parts[0], ip_parts[1], ip_parts[2]);
And with that we’ve completed all the requirement steps, just one more to go.
Just as simple as the other steps: POST to /api/clients
with some info:
async fn whitelist_client(sid: String, group: i32, client_cidr: String) {
let mut headers = header::HeaderMap::new();
let body = serde_json::json!({
"client": client_cidr,
"comment": "Comment",
"groups": [group]
});
headers.insert("X-FTL-SID", sid.parse().unwrap());
let res = reqwest::Client::new()
.post(format!("{}/clients", conf.api_url))
.json(&body)
.headers(headers)
.send()
.await
.unwrap();
println!("{:?}", res.text().await)
}
If that was successful, we are done! This endpoint likes to “fail with grace” by returning a 200 but error-ed, this usually happens when the client
is not unique, but it’s no big deal.
Let’s say you did a little opsie, blocked yourself, and your Pi-Hole instance is only accessible through a reverse proxy (a situation that has never happened to me, of course), how about now?
Well, if you are blocked, it means that you are not able to resolve any domain name (unless you have it set up in /etc/hosts
), but you still have internet access. This means we can use a little (unsafe) trick: just contact the server directly by it’s IP address.
To do that with cURL it’s pretty simple:
curl --request GET \
--url https://<ip>/api \
--header 'Host: pihole.example.org' \
-k
Since we are explicitly specifying the Host
header, TLS will freak out and fail. So we need to ignore it by using the -k
flag. This is of course insecure, but it solves our problem.
To implement it with reqwest
we will just need to create a client with this option:
let req_client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()
.unwrap();
Now, by replacing the usual Client::new()
with this client, it will be possible to ignore TLS errors. (Did I mentioned that this is insecure?)
Then you could pass the host with:
use reqwest::header;
let mut headers = header::HeaderMap::new();
headers.insert(header::HOST, "pihole.example.org".parse.unwrap())
Well, this is it. It was pretty fun to come up with this excuse to do some coding but solve this persistent problem. Hopefully this will be useful to someone else.
The full code is also available on my Codeberg profile if you want to check out everything.
See ya!