Giving Pi-hole's clients the names UniFi already knows
Pi-hole v6 shows clients as bare IPs unless you give them a description. A short stdlib Python script reads names from the UniFi controller and writes them through the Pi-hole API, then runs on a schedule as a CronJob. The API details and the traps in both.
The Pi-hole query log is much less useful when every client is an address. My router already knows the name of every device, because I’ve named them in its UI. I wanted those names in Pi-hole without typing them all again.
I considered local DNS records and conditional forwarding first, but what I actually wanted was the client’s description, which shows up in the log and the client list. That’s a different field, and Pi-hole v6’s API can set it.
The API, as far as this needs
# log in: returns a session id, sent as X-FTL-SID on later calls
POST /api/auth {"password": "..."}
GET /api/clients
POST /api/clients {"client": "<ip>", "comment": "<name>", "groups": [0]}
PUT /api/clients/<ip> {"comment": "<name>", "groups": [0]}
GET /api/network/devices
DELETE /api/auth # 204comment is the description. The thing that surprised me is that the clients
Pi-hole “autoregisters” aren’t clients at all. They’re rows of the network
table, which is a moving window. The set of addresses differed between a dry run
and the real run a few minutes later. Clients behind a router appear as
hwaddr: ip-<address> with no name, so Pi-hole can’t resolve them by itself.
What the script does
It takes a name per IP from the controller: the alias if there is one, then the DHCP hostname, then the first label of a local DNS record, then vendor plus the last bytes of the MAC. Access points and switches count too, but not the gateway’s public WAN address. Then it creates or updates the Clients entry.
Three choices keep it from making a mess:
- Only IPs that Pi-hole has actually seen get an entry. Otherwise the client list fills up with every phone the controller has ever remembered.
- An existing description wins. A name I set by hand in Pi-hole stays, unless I
pass
--force. --dry-runprints what would change. The second real run changed nothing, which is how I know it’s idempotent.
Because the network table moves, a later run can add newly seen addresses but it never removes one.
Running it every fifteen minutes
Since a name only appears once Pi-hole has seen the address, the script has to run
repeatedly. It’s two stdlib-only Python files, so the CronJob mounts them from a
ConfigMap that Kustomize generates from the files, and runs them in a plain
python:3-alpine image as a non-root user with a read-only root filesystem. It
talks to the Pi-hole’s in-cluster service over HTTP and takes the Pi-hole password
from the Secret that already exists. The controller’s API key comes from a second
Secret.
Two things I’d tell myself beforehand. The API key’s Secret has to be
decrypted by Flux, and mine
wasn’t at first. And to test the job without waiting for the schedule,
kubectl create job --from=cronjob/<name> <name>-test works; it created the
first entries for me within a minute. The idea of a CronJob that keeps external
state matching Git is the same one as on
the reconciling CronJob page.