Skip to content

Pi-hole

Pi-hole is the DNS resolver of most homelabs, and the appliance reads it as a DNS feed and a naming source. Every query with Pi-hole’s verdict lands in the same DNS events Zeek and Unbound produce — a name Pi-hole refused is marked blocked by resolver in the device detail — the clients Pi-hole knows become device names, and its network table proves which MAC holds which address.

It is a Free connector: no licence feature, no key. It exists for the network that runs UniFi and no OPNsense — the mirror sees encrypted DoH or nothing, and there is no Unbound to log — but the resolver saw every name every device asked for and knows which of them it refused. The AdGuard Home connector is the same feed from AdGuard.

Pi-hole has two API forms, and the connector speaks both. api_version: auto tries v6 first and remembers the answer for the life of the instance.

Form Endpoints Credential
v6 (Pi-hole 6.0 and later) POST /api/auth, /api/queries, /api/stats/summary, /api/info/version, /api/network/devices, /api/dhcp/leases, /api/clients An app password (Settings → Web interface/API → App password)
v5 (Pi-hole 5.18) /admin/api.php?getAllQueries, summaryRaw, getClientNames The API token (Settings → API → Show API token)

The v6 session id is reused across polls and renewed exactly once on a 401. A v5 token travels in the query string, because that is the only form v5 accepts; every operator-facing text is sanitised against it, and it never appears in an error or a health line. v5 reports no hardware address, so on v5 the connector proves no identity — it still delivers queries and names.

Everything is read-only. There is no endpoint the connector could write to and no option to ask Pi-hole to block anything.

Three things, through the connector framework and nothing else:

DNS query events. One event per answered or refused question, retained under sensor_id = connector:<instance> with source = pihole and trust firewall_app — the class of the Unbound feed: an application log, not packet evidence. The record carries the query, the client, the type and the return code plus the verdict: blocked, Pi-hole’s status word as the reason (GRAVITY, …), the upstream and whether the answer was cached. Everything that reads DNS events — the device detail’s domain list, the mDNS extraction of the identity and naming jobs, the domain features of the analysis — sees the resolver’s records without a change.

Naming candidates. One record per known client, tagged with where the name came from: dhcp for a lease Pi-hole holds (its own DHCP server), resolver for everything else — its hostname resolution, its hosts file, or a client comment an operator typed. Inside Pi-hole a lease outranks a resolved name and an operator’s client comment outranks both. See where a name comes from for the ranking across all sources.

Identity anchors. Where Pi-hole’s network table holds a hardware address, the client record carries mac (or mac_random) plus mac_oui, and dhcp_hostname for a lease — the same anchors the UniFi connector produces, written under source = connector:<instance>. A query event carries no anchor on purpose: a busy client asks hundreds of questions a minute, and its address is proven once per client list.

Pi-hole v6: Settings → Web interface/API → App password, generate one and enable app-password access. A normal login password works too, but an app password can be revoked on its own. Pi-hole v5: Settings → API → Show API token.

Store it as a credential file under the rules of UniFi, step 30640 root:nyxtrace or 0600 nyxtrace, nothing else:

Terminal window
sudo install -m 0640 -o root -g nyxtrace /dev/null /etc/ndr/secrets/pihole-password
sudo sh -c 'read -rs p && printf %s "$p" > /etc/ndr/secrets/pihole-password'

read -rs does not echo the value and leaves no shell history entry.

In /etc/ndr/connectors.yaml (the shipped example carries the same block):

connectors:
- id: pihole
instance: pihole
tenant: example
enabled: true
config:
url: http://pi.hole # or https://pihole.example.invalid
api_version: auto # auto | v6 | v5
# tls_fingerprint_sha256: "<sha256 of the leaf>" only for https with a self-signed certificate
poll_interval: 60s # floor 30s
page_size: 500 # 50..5000, twenty requests per poll at most
secrets:
password: file:/etc/ndr/secrets/pihole-password

NDR_CONNECTORS_ENABLED=true in /etc/ndr/ndr.env is required as for every connector declared in the file — see connectors.yaml. An https URL with a self-signed certificate takes the certificate pin the UniFi recipe reads; disabling verification is not offered.

3. The migrations, the grant and the backfill

Section titled “3. The migrations, the grant and the backfill”

The first start after the upgrade that brought this connector runs a schema migration (009) as the service’s pre-start step. It rewrites the DNS events view so it admits the resolver’s records, and that statement needs a DROP VIEW grant the appliance’s database user did not have before. The installer puts the updated users file in place; if you maintain the ClickHouse users yourself, install it before restarting, or the service start fails with Not enough privileges. See migrations.

Since 0.28 the same pre-start step also runs migration 010, which admits the resolver’s records into the hourly aggregate the device detail actually reads and adds the verdict to it. It needs no new grant and runs in seconds — but the rewritten view only sees records that arrive after it ran, so run the backfill once after the restart, while the service keeps serving: see migration 010 and the one-time backfill. Until it has run, the Domains (DNS) panel shows the resolver’s names and the blocked by resolver mark only from the upgrade on — never a wrong verdict.

As the service user, so the check proves the credential file is readable by the account that has to read it:

Terminal window
sudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true ndr connectors validate
sudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true ndr connectors test pihole --live
sudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true ndr connectors collect pihole --once --live

test --live reports the detected API form and version, the queries and blocked count of the day and the number of clients. collect --once --live prints how many query and client items one poll produced and how many mac, mac_random and dhcp_hostname anchors arrived, without printing a client name unless --verbose is given. It stores nothing.

A failure names its class: auth (app password or token rejected — v5 answers a rejected token as an empty list, which is reported as auth), unreachable, timeout, tls (self-signed certificate without a pin, or a wrong pin), unexpected_shape (neither /api nor /admin/api.php, or a cut-off answer), rate_limited (honouring Retry-After), upstream_error, too_large.

NDR_CONNECTORS_ENABLED=true in /etc/ndr/ndr.env and systemctl restart ndr-api, as for every connector. The system view then shows connector:pihole with its state, the time of the last poll and the counters:

Counter Meaning
queries, blocked Delivered by the last poll
clients Known clients in the last client list
queries_today, blocked_today, blocklist_domains Pi-hole’s own summary numbers

The instance turns stale after three missed polls, and degraded — with the reason on the health line — when a poll refused rows, see a resolver with a wrong clock. Without the interface:

Terminal window
journalctl -u ndr-api -g 'instance=pihole'

The first poll reaches fifteen minutes back; from then on the cursor continues where the last poll stopped, across restarts. The client list is re-emitted whenever it changes and once an hour anyway, so the anchors stay observed.

Device name. The hourly naming job ranks a lease name as dhcp (rank 4, via DHCP) and any other Pi-hole name as resolver (rank 6, via resolver) — after mDNS, because the device said that about itself, and before reverse DNS, because the appliance’s own PTR lookup asks the same resolver one hop later with less context. A name the connector has not re-listed for three days is shown as a former name.

Device detail. The Domains (DNS) panel lists the names the device asked for, with blocked by resolver next to a name Pi-hole refused at least once in the window. It is the resolver’s verdict, not a finding: no detection model scores it.

Incidents. Evidence of kind dns with source: pihole.

API. Each domains row of the device detail carries the boolean resolver_blocked; name_source can be resolver; evidence references can carry source: pihole. See endpoints.

Both resolver connectors stamp a query with the resolver’s own clock, and the cursor that remembers where the last poll stopped is a timestamp. Since 0.27 a query stamped more than five minutes ahead of the appliance clock is refused: it is counted as failed with the reason query time is more than 5m ahead of the appliance clock, and the instance shows degraded with that reason in the system view and in GET /api/v1/health until a poll refuses nothing. The cursor never moves past the present, so a skewed — or hostile — source cannot stall the feed: with the bookmark in the future, every real query would read as already delivered. A bookmark that an earlier release did let run into the future is rewound on the next poll (reason bookmark was in the future and was rewound to the beginning), which re-reads the first window once; the event table’s replacing key absorbs the repeat.

Fix the resolver’s clock (NTP) and the state clears on its own.

To clear an instance’s bookmark by hand — after a resolver reinstall, say — stop the service, reset the cursor as the service user, then start the service again:

Terminal window
systemctl stop ndr-api
systemd-run --pipe --quiet --collect --uid=nyxtrace \
-p EnvironmentFile=/etc/ndr/ndr.env -p EnvironmentFile=/etc/ndr/netflow.env \
/usr/local/bin/ndr connectors reset-cursor pihole
systemctl start ndr-api

The order matters. A running ndr-api keeps the old bookmark in memory and writes it back after its next poll, which would undo the reset. The command needs the service environment (NDR_STATE_DB_PATH and connectors.yaml) and must not run as root, because SQLite would leave root-owned -wal/-shm files behind. It deletes the cursor row of that one instance and touches nothing else; the next poll starts from the beginning — fifteen minutes back for Pi-hole, the newest page for AdGuard Home — and queries that were already delivered are absorbed by the event table’s replacing key. The command works for every connector instance, not only the resolvers.

  • Poll 60 s, floor 30 s. One request 5 s, one collection 1 min, one answer 4 MiB.
  • page_size 50..5000 and at most twenty requests per poll. At the default of 500 that is 10,000 queries per poll; a poll that needs more is reported as truncated, and because the log is walked newest first, the queries that fall off are the oldest of the window. Lower the interval or raise the page size on a resolver that busy.
  • The v5 form answers a whole time range unpaged, so one v5 poll covers at most ten minutes; a connector that fell behind catches up in steps.
  • Queries from a client outside the tenant’s own networks are dropped and counted in the dropped= field of the poll’s log line.
  • A query stamped more than five minutes ahead of the appliance clock is refused and counted as failed, and the instance is degraded with that reason until a poll refuses nothing — see a resolver with a wrong clock.
  • Stale after three missed polls; a client record is a former name after 72 h without re-listing.
  • The v5 form reports no hardware address, so on Pi-hole 5 the connector proves no identity.
  • resolver_blocked is shown and stored, but no detection model reads it yet.
  • Two Pi-holes are two entries with different instance names; each keeps its own cursor, backoff and health line.