Give Claude Code Pi-hole Access on the Raspberry Pi
This page combines the steps from Give Claude Code Access to Pi-hole into a single step-by-step tutorial for users who:
- Run Claude Code on the Pi itself
- Use their own existing login account
Install Claude Code
Section titled “Install Claude Code”-
Install Claude Code:
From the Pi curl -fsSL https://claude.ai/install.sh | bash -
Start it and log in:
From the Pi claudeOn 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 promptedprompt.
Grant Read Access
Section titled “Grant Read Access”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.
-
Install the ACL tools. They aren’t present by default:
From the Pi sudo apt install acl -
Apply the grant. Three
setfaclcalls, 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 && \sudo setfacl -m "u:${USER}:r" /etc/pihole/pihole-FTL.db-wal /etc/pihole/pihole-FTL.db-shm
Explanations:
-R -m u:...:rXon the log directoryXmeans “execute only on directories,” so the directory getsr-xand every log file getsr--.- A lowercase
rhere 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, sou: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:...:ron the log directory- Sets a default ACL, which is what survives log rotation.
- Pi-hole rotates
pihole.logdaily andFTL.logweekly withcreate 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.1and.2archives.
u:...:ron the query database- Needs no directory ACL:
/etc/piholeis already world-traversable. - This grant also survives Pi-hole restarts. FTL resets the database to mode
0640every time it starts, andchmodrewrites an ACL’s mask rather than removing named-user entries, so the read stays effective.
- Needs no directory ACL:
u:...:ronpihole-FTL.db-walandpihole-FTL.db-shmpihole-FTL.dbruns in SQLite’s WAL journal mode, so a reader also needs to open these two sidecar files. Granting the.dbfile alone fails withParse error in 3rd command line argument: unable to open database file (14), an error that reads like a syntax problem and isn’t.- This has to be a per-file grant, not a default ACL on
/etc/piholeitself. That directory also holdspihole.toml, which this guide separately and correctly keeps off limits (it stores API password hashes in plaintext); a directory-wide default ACL would silently expose it to any account with the default grant. - Not yet confirmed whether this grant survives an FTL restart the way the
.dbgrant does. FTL may recreate-shmon restart the same way it resets the database’s mode, which would drop the ACL along with it. If queries that worked start failing with the same error after restarting FTL, re-run this command.
Read Query History
Section titled “Read Query History”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:
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:
| Status | Meaning |
|---|---|
1 | Blocked by a blocklist (gravity) |
2 | Forwarded upstream |
3 | Answered from cache |
4 | Blocked by a regex filter |
5 | Blocked 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.
Verify the Boundaries
Section titled “Verify the Boundaries”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:
echo test >> /var/log/pihole/pihole.logThat should report Permission denied.
Then check pihole.toml:
head -n 1 /etc/pihole/pihole.tomlThe result depends on your Pi-hole version, and both outcomes are expected:
Permission deniedmeans the file is mode0640, 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:
getfacl -p /var/log/pihole /etc/pihole/pihole-FTL.db*If a query still fails after that, run this as the granted account to tell “the ACL isn’t there” apart from “my query syntax is wrong” in one shot, rather than debugging both possibilities against a real query at once:
getfacl /etc/pihole /etc/pihole/pihole-FTL.db* 2>&1; echo ---; pihole-FTL sqlite3 --readonly /etc/pihole/pihole-FTL.db 'SELECT 1;'Configure Claude Code
Section titled “Configure Claude Code”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:
{ "permissions": { "deny": [ "Read(/etc/pihole/pihole.toml)", "Bash(sudo:*)" ], "allow": [ "Bash(pihole-FTL sqlite3 --readonly:*)", "Bash(pihole status)", "Bash(dig:*)", "Bash(tailscale status)" ] }}Give the Agent a Context File
Section titled “Give the Agent a Context File”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
sudocommands 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
# 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 Codeprompt, for example `! sudo pihole -g`. That runs it in my terminal, where a passwordprompt can actually reach me, and puts the output back into your context so you cankeep going.
You also can't change lists. `pihole allow` and `pihole deny` don't need root, but theyauthenticate by reading `/etc/pihole/cli_pw`, which you can't read, so instead of failingthey 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 servicestatus 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:
```shellpihole-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`:
```shellpihole-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:
```shellpihole-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:
```shellpihole-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.