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.
Create the Agent User
Section titled “Create the Agent User”Nothing to create. Claude Code runs as your existing account.
Skip to Install Claude Code.
-
Create the user with a home directory and no
sudogroup membership:From the Pi sudo useradd -m -s /bin/bash claude-agentFrom your workstation ssh -t pi-hole-admin 'sudo useradd -m -s /bin/bash claude-agent' -
Tighten its home directory so other accounts can’t read its session data:
From the Pi sudo chmod 750 /home/claude-agentFrom your workstation ssh -t pi-hole-admin 'sudo chmod 750 /home/claude-agent' -
Give it a working directory, since Claude Code needs somewhere it can write:
From the Pi sudo -u claude-agent mkdir -p /home/claude-agent/piholeFrom your workstation ssh -t pi-hole-admin 'sudo -u claude-agent mkdir -p /home/claude-agent/pihole' -
Set up access to the account:
Allow your own account to become it. This rule lets you become
claude-agent. It grantsclaude-agentnothing:From the Pi echo "$USER ALL=(claude-agent) ALL" | sudo tee /etc/sudoers.d/claude-agent-switch && \sudo chmod 440 /etc/sudoers.d/claude-agent-switch && \sudo visudo -c -f /etc/sudoers.d/claude-agent-switchThe last command should report
parsed OK.Give the account its own SSH key so your workstation can reach it directly:
From your workstation ssh-keygen -t ed25519 -f ~/.ssh/claude-agent -C "claude-agent@pi-hole"Add a second host alias for it, alongside the
pi-hole-adminone you defined earlier:~/.ssh/config Host pi-hole-agentHostName 192.168.0.153User claude-agentIdentityFile ~/.ssh/claude-agentIdentitiesOnly yesssh-copy-idwon’t install the key:useraddleaves the account password-locked, so there is no password login to authenticate against. Install it through your administrative session instead:From your workstation PUBKEY="$(cat ~/.ssh/claude-agent.pub)" && \ssh -t pi-hole-admin "sudo -u claude-agent mkdir -p -m 700 /home/claude-agent/.sshecho '$PUBKEY' | sudo -u claude-agent tee -a /home/claude-agent/.ssh/authorized_keys >/dev/nullsudo -u claude-agent chmod 600 /home/claude-agent/.ssh/authorized_keys"The key goes into a shell variable rather than through a pipe on purpose. Piping into
ssh -tsuppresses the terminal it just asked for, andsudoon the far end fails with the same password prompt error this page opened with.Leaving the account password-locked is the point. The key is the only way in, and it opens a shell with no
sudobehind it.Confirm the alias lands in the right account:
From your workstation ssh pi-hole-agent 'id'That should report
uid=... (claude-agent), which is sshd enforcing the identity rather than Claude Code choosing it.Setup commands on the rest of this page use
pi-hole-admin, because they needsudo. Claude Code only ever usespi-hole-agent.
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.
The curl installer places Claude Code in ~/.local/bin for the account that runs it, so installing it as yourself leaves claude-agent without the command.
Install from the apt repository instead, which puts it on the system path for every account:
-
Add the signing key and repository:
From the Pi sudo install -d -m 0755 /etc/apt/keyrings && \sudo curl -fsSL https://downloads.claude.ai/keys/claude-code.asc \-o /etc/apt/keyrings/claude-code.asc && \echo "deb [signed-by=/etc/apt/keyrings/claude-code.asc] https://downloads.claude.ai/claude-code/apt/stable stable main" \| sudo tee /etc/apt/sources.list.d/claude-code.list -
Install it:
From the Pi sudo apt update && \sudo apt install claude-code -
Enter the agent account and log in as that account.
This is the one time you switch users, and it’s interactive, so the password prompt is fine here:
- Log in:
From the Pi sudo -u claude-agent -iThe login shows a code rather than redirecting, because the browser can’t reach a headless Pi.
- Change directories and start Claude Code:
From the Pi cd ~/pihole && \claudeOpen the URL elsewhere when prompted, sign in, and paste the code back.
This is a second login for the same Claude account, not a second subscription. To skip the browser flow, run
claude setup-tokenon a machine you’re already signed in on and setCLAUDE_CODE_OAUTH_TOKENin the agent’s shell instead.
Install Claude Code on your workstation using the instructions for your platform, then confirm it reaches the Pi:
claude --version && \ssh pi-hole-admin 'pihole status'claude --versionYou already confirmed ssh pi-hole-agent 'id' lands in the agent account when you created it.
Nothing gets installed on the Pi.
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 aclFrom your workstation ssh -t pi-hole-admin '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.dbFrom the Pi sudo setfacl -R -m u:claude-agent:rX /var/log/pihole && \sudo setfacl -m d:u:claude-agent:r /var/log/pihole && \sudo setfacl -m u:claude-agent:r /etc/pihole/pihole-FTL.dbReplace
pi-adminwith your own account name on the Pi:From your workstation ssh -t pi-hole-admin 'sudo setfacl -R -m u:pi-admin:rX /var/log/pihole && \sudo setfacl -m d:u:pi-admin:r /var/log/pihole && \sudo setfacl -m u:pi-admin:r /etc/pihole/pihole-FTL.db'From your workstation ssh -t pi-hole-admin 'sudo setfacl -R -m u:claude-agent:rX /var/log/pihole && \sudo setfacl -m d:u:claude-agent:r /var/log/pihole && \sudo setfacl -m u:claude-agent:r /etc/pihole/pihole-FTL.db'
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:
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;"ssh pi-hole-admin "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;\""ssh pi-hole-agent "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.
Each of these should report Permission denied.
Run them one at a time, since each is expected to fail and chaining them would stop at the first:
sudo -u claude-agent bash -c 'echo test >> /var/log/pihole/pihole.log'sudo -u claude-agent head -n 1 /home/pi-admin/.ssh/id_ed25519sudo -u claude-agent head -n 1 /etc/shadowReplace /home/pi-admin with your own home directory.
Then check pihole.toml:
sudo -u claude-agent 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.
Writing to the log should fail, because the ACL grants read only:
ssh pi-hole-admin 'echo test >> /var/log/pihole/pihole.log'That should report Permission denied.
Then check pihole.toml:
ssh pi-hole-admin 'head -n 1 /etc/pihole/pihole.toml'The 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.
Each of these should report Permission denied.
Run them one at a time, since each is expected to fail and chaining them would stop at the first:
ssh -t pi-hole-admin "sudo -u claude-agent bash -c 'echo test >> /var/log/pihole/pihole.log'"ssh -t pi-hole-admin 'sudo -u claude-agent head -n 1 /home/pi-admin/.ssh/id_ed25519'ssh -t pi-hole-admin 'sudo -u claude-agent head -n 1 /etc/shadow'Replace /home/pi-admin with your own home directory on the Pi.
Then check pihole.toml:
ssh -t pi-hole-admin 'sudo -u claude-agent head -n 1 /etc/pihole/pihole.toml'The 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.dbssh pi-hole-admin 'getfacl -p /var/log/pihole /etc/pihole/pihole-FTL.db'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)" ] }}Add to .claude/settings.json in the project directory you run Claude Code from:
{ "permissions": { "allow": [ "Bash(ssh pi-hole-admin:*)" ] }}What ACL for Claude Code Can and Can’t Do
Section titled “What ACL for Claude Code Can and Can’t Do”pi-hole-admin is your own account, so the allow rule lets Claude Code run anything on the Pi as you, sudo included if your account has passwordless sudo (even if temporarily because you entered the password recently).
The ACL and the file modes are the only real limits, which is the tradeoff you accepted by running as your login user.
{ "permissions": { "deny": [ "Bash(ssh pi-hole-admin:*)", "Bash(ssh -t pi-hole-admin:*)" ], "allow": [ "Bash(ssh pi-hole-agent:*)" ] }}The deny rules keep Claude Code off the administrative alias, so it can’t reach the account that has sudo.
claude-agent account’s own permissions on the Pi restrict the agent alias itself, not the contents of this file.
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.