Skip to content

Set Up Read-Only Pi-hole Access for Claude Code

This is step two of the staged guide. Start at Give Claude Code Access to Pi-hole if you haven’t picked your setup yet.

Nothing to create. Claude Code runs as your existing account.

Skip to Install Claude Code.

  1. Install Claude Code:

    From the Pi
    curl -fsSL https://claude.ai/install.sh | bash
  2. Start it and log in:

    From the Pi
    claude

    On a headless Pi the browser can’t reach Claude Code’s local callback, so the login shows a code instead of redirecting. Open the URL on another machine, sign in, then paste the code back at the Paste code here if prompted prompt.

Tested

POSIX ACLs grant one account read access to one file. Unlike group membership, an ACL can be scoped per file, is revocable without touching the account, and grants no write access anywhere.

Every command in this section runs with sudo on the Pi.

  1. Install the ACL tools. They aren’t present by default:

    From the Pi
    sudo apt install acl
  2. Apply the grant. Three setfacl calls, chained so the run stops if any one fails:

    From the Pi
    sudo setfacl -R -m "u:${USER}:rX" /var/log/pihole && \
    sudo setfacl -m "d:u:${USER}:r" /var/log/pihole && \
    sudo setfacl -m "u:${USER}:r" /etc/pihole/pihole-FTL.db

Explanations:

  • -R -m u:...:rX on the log directory
    • X means “execute only on directories,” so the directory gets r-x and every log file gets r--.
    • A lowercase r here instead breaks the whole grant, and fails in a way that looks like the ACL never applied: a named-user entry replaces whatever the account was getting from the directory’s “other” class, so u:agent:r-- on the directory strips the execute bit it needs to enter the directory at all, and every read below it is denied.
  • d:u:...:r on the log directory
    • Sets a default ACL, which is what survives log rotation.
    • Pi-hole rotates pihole.log daily and FTL.log weekly with create 640 pihole pihole, building a brand new file each time. An ACL set only on today’s file is gone tomorrow. A default ACL is inherited by every file created in the directory afterward, including the compressed .1 and .2 archives.
  • u:...:r on the query database
    • Needs no directory ACL: /etc/pihole is already world-traversable.
    • This grant also survives Pi-hole restarts. FTL resets the database to mode 0640 every time it starts, and chmod rewrites an ACL’s mask rather than removing named-user entries, so the read stays effective.
Per vendor docs

pihole-FTL ships an embedded SQLite shell, so no separate sqlite3 package is needed. Always pass --readonly, which makes writes impossible at the connection level.

Run this as the account you just granted access to. It doubles as proof the grant works. Replace YOUR-DEVICE-IP with the IP of a device on your network you want to check:

From the Pi
pihole-FTL sqlite3 --readonly /etc/pihole/pihole-FTL.db \
"SELECT datetime(timestamp,'unixepoch','localtime'), client, domain, status
FROM queries
WHERE client = 'YOUR-DEVICE-IP'
ORDER BY timestamp DESC LIMIT 20;"

The queries view resolves the internal integer IDs back to real domain and client strings, so you don’t need to join anything. status stays numeric.

The values are:

StatusMeaning
1Blocked by a blocklist (gravity)
2Forwarded upstream
3Answered from cache
4Blocked by a regex filter
5Blocked by the denylist

The database lags live traffic by up to 60 seconds because FTL flushes its in-memory buffer on database.DBinterval, which defaults to 60.

Tested

Confirm what the account can’t do as that account (rather than as yourself).

Writing to the log should fail, because the ACL grants read only:

From the Pi
echo test >> /var/log/pihole/pihole.log

That should report Permission denied.

Then check pihole.toml:

From the Pi
head -n 1 /etc/pihole/pihole.toml

The result depends on your Pi-hole version, and both outcomes are expected:

  • Permission denied means the file is mode 0640, and the exclusion holds.
  • A line of config means the file is mode 0644, so it is readable by every account on the Pi. Nothing is broken, but the deny rule in the next section is doing real work, and you should treat those password hashes as exposed locally.

To see the grant itself:

From the Pi
getfacl -p /var/log/pihole /etc/pihole/pihole-FTL.db
Per vendor docs

Permission rules are enforced by Claude Code, not the kernel.

Claude’s own documentation notes that Bash patterns constraining arguments are fragile, so these rules are defense in depth: they stop an honest mistake, and the ACL is what holds when a rule is bypassed.

That ordering matters most for pihole.toml, since on a 0644 host the deny rule is the only thing standing between an agent and your password hashes.

Add to ~/.claude/settings.json in the home directory of the account Claude Code runs as:

~/.claude/settings.json
{
"permissions": {
"deny": [
"Read(/etc/pihole/pihole.toml)",
"Bash(sudo:*)"
],
"allow": [
"Bash(pihole-FTL sqlite3 --readonly:*)",
"Bash(pihole status)",
"Bash(dig:*)",
"Bash(tailscale status)"
]
}
}

Access alone isn’t enough. An agent that doesn’t know how your network is put together can search for the wrong thing and report a confident non-answer.

The file below is a working CLAUDE.md for this setup. Copy it to the directory you run Claude Code from and replace the host details with your own.

  • The Privileges section stops the agent from wasting tokens on sudo commands it can’t run.
    • Carries two commented-out blocks. Uncomment the matching one if you add either optional grant below.
  • The Gotchas section encodes the failures that look like real findings rather than mistakes.
CLAUDE.md
CLAUDE.md
# Pi-hole environment
Context for Claude Code working on this Pi-hole install.
Replace the placeholder values with your own before you use this.
## Host
- Pi-hole host: `pi-hole` at `192.168.0.153`, interface `eth0`
- Router and gateway: `192.168.0.1`
- Versions: Core v6.4.3, Web v6.6, FTL v6.7
- My login account: `pi-admin`
## Privileges
You have no `sudo` access, and `sudo` cannot prompt for a password in your shell.
Don't try privileged commands. They will fail with `a terminal is required to read the password`.
If something genuinely needs root, print the command and ask me to run it.
Tell me to run it in shell mode by typing `!` followed by the command at the Claude Code
prompt, for example `! sudo pihole -g`. That runs it in my terminal, where a password
prompt can actually reach me, and puts the output back into your context so you can
keep going.
You also can't change lists. `pihole allow` and `pihole deny` don't need root, but they
authenticate by reading `/etc/pihole/cli_pw`, which you can't read, so instead of failing
they hang waiting for a password prompt you can't see. Hand those commands to me the same way.
<!-- If you granted read access to /etc/pihole/cli_pw, uncomment this:
You can run `pihole allow`, `pihole deny`, `pihole enable`, and `pihole disable` directly,
with no sudo. Ask me first before disabling blocking or removing a list.
-->
<!-- If you set up the diagnostic wrapper, uncomment this:
`sudo -n /usr/local/sbin/pihole-agent-diag` runs without a password and prints FTL service
status plus the last 100 FTL log lines. It takes no arguments.
-->
## What you can read
Granted explicitly by ACL:
- `/var/log/pihole/pihole.log`: raw dnsmasq query log
- `/var/log/pihole/FTL.log`: FTL engine log, for startup and DNSSEC errors
- `/etc/pihole/pihole-FTL.db`: long-term query database
World-readable, no grant needed:
- `/etc/pihole/gravity.db`: blocklists, groups, clients, adlists
## What you must not read
`/etc/pihole/pihole.toml` is off limits.
It stores `webserver.api.pwhash` and `webserver.api.app_pwhash` in plain text, and reading the file pulls my password hashes into this transcript.
To read a single config value, read only that value:
```shell
pihole-FTL --config webserver.api.app_sudo
```
## Querying the data
The standalone `sqlite3` binary isn't installed. Use the one built into FTL, always with `--readonly`:
```shell
pihole-FTL sqlite3 --readonly /etc/pihole/pihole-FTL.db \
"SELECT datetime(timestamp,'unixepoch','localtime'), client, domain, status
FROM queries
WHERE client = '203.0.113.42'
ORDER BY timestamp DESC LIMIT 20;"
```
The `queries` view resolves internal integer IDs to real domain and client strings, so no joins are needed.
`status` is numeric: `1` blocked by blocklist, `2` forwarded, `3` from cache, `4` blocked by regex, `5` blocked by denylist.
The database lags live traffic by up to 60 seconds (`database.DBinterval`).
If a query you just made is missing, wait a minute before concluding it didn't happen.
Read `gravity.db` the same way:
```shell
pihole-FTL sqlite3 --readonly /etc/pihole/gravity.db "SELECT id, address, enabled FROM adlist;"
```
## Gotchas that have cost time before
### Devices appear under their Tailscale IP
Tailscale is in use here.
While a device is connected to the tailnet, its DNS queries arrive over `tailscale0` and the query log records its `100.x.x.x` address, not its LAN IP.
Filtering by the LAN IP returns nothing at all, which looks identical to "this device isn't using Pi-hole."
Run `tailscale status` first and map the device both ways.
Active peers show the LAN address they connected from, such as `direct 203.0.113.42:41641`.
### Tailnet devices fall back to the Default group
Pi-hole matches clients by MAC address, and MAC lookups depend on ARP, which doesn't exist on `tailscale0`.
A device connected over Tailscale can't be matched to a client entry created by MAC, so it falls back to the `Default` group no matter what group I assigned it.
Check which group a client actually resolves to before explaining its blocking behavior.
FTL also matches clients by interface and prefers the interface queries actually arrive on, so a single client entry of `:tailscale0` covers every tailnet device at once.
### Blocklists are split across group bundles
Adlists are grouped into reusable bundles rather than assigned per device.
Read the current mapping instead of assuming:
```shell
pihole-FTL sqlite3 --readonly /etc/pihole/gravity.db \
"SELECT g.name, group_concat(a.id) FROM 'group' g
LEFT JOIN adlist_by_group abg ON abg.group_id = g.id
LEFT JOIN adlist a ON a.id = abg.adlist_id
GROUP BY g.id;"
```
Group membership is a union, not an override.
A client in two groups gets the blocklists of both.
### Blocklist files use Adblock Plus syntax
The HaGeZi lists in `/etc/pihole/listsCache/` and the `gravity` table store entries as `||example.com^`, not bare domains.
Grepping for `^example\.com$` returns nothing and looks like a real absence.
Match on `\|\|example\.com\^` instead.
### Allow and deny changes don't need a gravity rebuild
`pihole allow` and `pihole deny` write to the `domainlist` table and FTL picks them up immediately.
Only adlist changes need `pihole -g`, which takes several minutes and rewrites the whole gravity database.
### Comments reject some punctuation
`pihole allow --comment` accepts only `[a-zA-Z0-9_#:/.,\ -]`.
A `+` in a comment fails the whole command.