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.
What it reads
Section titled “What it reads”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.
What it emits
Section titled “What it emits”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.
Set it up
Section titled “Set it up”1. Create the credential
Section titled “1. Create the credential”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 3 —
0640 root:nyxtrace or 0600 nyxtrace, nothing else:
sudo install -m 0640 -o root -g nyxtrace /dev/null /etc/ndr/secrets/pihole-passwordsudo 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.
2. Declare the instance
Section titled “2. Declare the instance”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-passwordNDR_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.
4. Prove it before the service polls it
Section titled “4. Prove it before the service polls it”As the service user, so the check proves the credential file is readable by the account that has to read it:
sudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true ndr connectors validatesudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true ndr connectors test pihole --livesudo -u nyxtrace env NDR_CONNECTORS_ENABLED=true ndr connectors collect pihole --once --livetest --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.
5. Switch on and verify
Section titled “5. Switch on and verify”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:
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.
Where it shows up
Section titled “Where it shows up”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.
A resolver with a wrong clock
Section titled “A resolver with a wrong clock”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.
Starting an instance over
Section titled “Starting an instance over”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:
systemctl stop ndr-apisystemd-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 piholesystemctl start ndr-apiThe 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.
Bounds
Section titled “Bounds”- Poll 60 s, floor 30 s. One request 5 s, one collection 1 min, one answer 4 MiB.
page_size50..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 astruncated, 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
degradedwith 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.
Known limits
Section titled “Known limits”- The v5 form reports no hardware address, so on Pi-hole 5 the connector proves no identity.
resolver_blockedis shown and stored, but no detection model reads it yet.- Two Pi-holes are two entries with different
instancenames; each keeps its own cursor, backoff and health line.