A self-contained playbook distilled from a real debugging session on a Linux box running
Private Internet Access 3.7.2 inside a Lerd dev sandbox (podman + pasta + dnsmasq on
127.0.0.1:5300). Two distinct root causes were found and fixed. Everything below is meant
to be re-runnable on a different machine, with per-machine values called out so you can adapt it.
- Symptoms
- Root cause 1 — UDP DNS poisoning of the PIA zone
- Root cause 2 — PIA DNS collides with Lerd on port 5300
- How to diagnose on a NEW machine
- The fixes (files + commands)
- Post-reboot health check
- The safety rig: breaker + capture-while-connected
- Key learnings
- Per-machine values to adapt
- Cleanup / uninstall
- The PIA app either can't connect at all, or connects (shows a VPN IP) but no site loads.
- While connected, Chrome shows
DNS_PROBE_STARTED/DNS_PROBE_FINISHED_NO_INTERNET. - Disconnecting restores the Internet immediately.
- Sometimes DNS works for the first curl right after connect, then dies.
The root pattern here: the VPN tunnel and routing are perfectly healthy; the system DNS chain is what breaks while connected.
The network (ISP/router/upstream filter) returns forged NXDOMAIN for every
*.privateinternetaccess.com name over plain UDP port 53 — while DNS-over-HTTPS resolves
the zone fine, DNSSEC-validated. Because the PIA daemon resolves its API over the system
resolver, it fails with QNetworkReply::HostNotFoundError and can't refresh status/tokens →
"app can't connect".
Signature check (do this on any new machine):
# 1. Plain UDP, every resolver you can reach:
dig @127.0.0.1 -p 5300 api.privateinternetaccess.com +short # NXDOMAIN -> poisoned
dig @192.168.88.1 api.privateinternetaccess.com +short # NXDOMAIN -> poisoned
dig @8.8.8.8 api.privateinternetaccess.com +short # NXDOMAIN -> poisoned (even direct!)
# 2. DNS-over-HTTPS (bypasses UDP tampering):
curl -sS -m 8 -H 'accept: application/dns-json' \
'https://cloudflare-dns.com/dns-query?name=api.privateinternetaccess.com&type=A'
# -> Status:0 + real A records means the zone is healthy globally and UDP is being poisoned.If @8.8.8.8/@1.1.1.1 also answer NXDOMAIN (with aa flag) while DoH succeeds → network-level
UDP DNS poisoning/filtering of that zone. Fix by pinning the zone locally.
- PIA has
overrideDNS: "pia"in/opt/piavpn/etc/settings.json. On connect it:- sets
tun0's per-link DNS to its own resolver (10.0.0.243over the tunnel) — this works, - starts its internal resolver (
hnsd) bound to127.0.0.1:5300, - installs firewall rules (
blockDNS/allowDNS) so only that resolver may be used.
- sets
- The Lerd sandbox permanently owns
127.0.0.1:5300(pasta forwards it to a podman dnsmasq). PIA'shnsdtherefore never binds, and Lerd's DNS watcher (a Go daemon, not just the NetworkManager dispatcher!) re-claims the VPN interface's per-link DNS:
tun0: Bus client set DNS server list to: 10.0.0.243 <- PIA (good)
tun0: Bus client set search domain list to: ~.
tun0: Bus client set DNS server list to: 127.0.0.1:5300 <- Lerd (kills it)
tun0: Bus client set search domain list to: ~test, ~.
- Result: every system DNS query is forced at the hijacked
127.0.0.1:5300, which is blackholed by PIA'sblockDNSwhile the tunnel is up →DNS_PROBE_*errors even thoughping 8.8.8.8,curlto IPs, and directdig @10.0.0.243all work through the tunnel.
journalctl -u systemd-resolved is the forensic tool that shows exactly who set what per-link DNS.
id -u; command -v piactl openvpn wg dig resolvectl systemctl
ls -la /opt/piavpn/var /opt/piavpn/etc 2>/dev/null # daemon.log, settings.json, pia.ovpn
systemctl list-units --all | grep -iE 'pia|openvpn' # piavpn.service etc.
ip -brief addr; ip route show; ip rule show # routes, policy rules
find ~/.config /etc /opt -maxdepth 3 \( -iname '*lerd*' -o -iname '*pia*' \) 2>/dev/null | head -30piactl get connectionstate; piactl get region; piactl get protocol
grep -E 'api/client/status|Request for "api' /opt/piavpn/var/daemon.log | tail -10
# HostNotFoundError here => resolver can't reach api.privateinternetaccess.comcat /opt/piavpn/etc/settings.json | grep -o '"overrideDNS":"[^"]*"' # pia|custom|system
resolvectl status | grep -E 'DNS Servers|Current DNS'
journalctl -u systemd-resolved --since '10 min ago' | grep 'tun0:' # who sets tun0 DNSNever connect blind: arm the breaker + capture rig first, connect, read the capture. It answers, in one run:
- tunnel data path (ping/curl) OK? → then it's DNS
dig @10.0.0.243answers over the tunnel? → PIA tunnel DNS worksdig @127.0.0.1 -p 5300anddig @<router>fail while connected? → the 5300/blocker conflictgetent hosts google.comempty while connected? → system DNS broken
Verifies IPs first via DoH, then adds a separate dnsmasq conf so Lerd's regenerated
lerd.conf (which only rewrites lerd.conf) won't clobber it:
# Re-verify the real IPs (they can change; Cloudflare-fronted):
IP1=$(curl -sS -m 8 -H 'accept: application/dns-json' \
'https://cloudflare-dns.com/dns-query?name=api.privateinternetaccess.com&type=A' \
| grep -oE '"data":"[0-9.]+"' | head -1 | cut -d'"' -f4)
IP2=$(curl -sS -m 8 -H 'accept: application/dns-json' \
'https://cloudflare-dns.com/dns-query?name=api.privateinternetaccess.com&type=A' \
| grep -oE '"data":"[0-9.]+"' | sed -n 2p | cut -d'"' -f4)
# Lerd dnsmasq reads every *.conf in this user dir:
mkdir -p ~/.local/share/lerd/dnsmasq
printf 'address=/privateinternetaccess.com/%s/%s\n' "$IP1" "$IP2" \
> ~/.local/share/lerd/dnsmasq/99-pia.conf
# Restart the Lerd DNS container (user-level podman container via quadlet):
systemctl --user restart lerd-dns
# Verify:
dig @127.0.0.1 -p 5300 api.privateinternetaccess.com +short # -> real IPs now
curl -m 8 -sS -o /dev/null -w 'HTTP %{http_code}\n' https://api.privateinternetaccess.com/api/client/statusOn a non-Lerd machine, skip the pin and instead check whether you can reach the API; the poisoning may be absent there.
On every Connected, re-assert tun0 → 10.0.0.243 after Lerd's watcher claims the
interface. It relies on the passwordless resolvectl grant that Lerd installs via
/etc/sudoers.d/lerd (test with sudo -n resolvectl dns enp1s0 127.0.0.1:5300 192.168.88.1).
~/.local/bin/pia-dns-helper.sh (full file — self-contained so it survives reboots):
#!/usr/bin/env bash
# pia-dns-helper: keep tun0 DNS pinned to PIA tunnel resolver (10.0.0.243)
# after each connect. PIA's DNS works, but the Lerd watcher claims tun0 right
# after connect and points it at 127.0.0.1:5300 (dead while the VPN is up).
# This re-asserts PIA's tunnel DNS a few seconds after every Connected.
#
# Self-contained: state/logs live under ~/.local/state/pia-dns-helper so it
# survives reboots. (The original version logged to /tmp/pia_safe/, which is
# wiped on reboot: absent parent dir => every ">> log" redirect fails => the
# sudo resolvectl commands silently never ran.)
STATEDIR="${XDG_STATE_HOME:-$HOME/.local/state}/pia-dns-helper"
mkdir -p "$STATEDIR" 2>/dev/null || exit 1
LOG="$STATEDIR/helper.log"
STOPFLAG="$STATEDIR/stop"
log(){ printf '[%s] %s\n' "$(date '+%F %T')" "$*" >> "$LOG" 2>/dev/null || true; }
fix(){
sleep 5 # let PIA set tun0 and Lerd claim it first
log "pass1: tun0 -> 10.0.0.243"
sudo -n resolvectl dns tun0 10.0.0.243 >> "$LOG" 2>&1 || log "pass1 dns FAILED"
sudo -n resolvectl domain tun0 ~. >> "$LOG" 2>&1 || log "pass1 domain FAILED"
sleep 12
log "pass2: re-assert tun0 -> 10.0.0.243"
sudo -n resolvectl dns tun0 10.0.0.243 >> "$LOG" 2>&1 || log "pass2 dns FAILED"
sudo -n resolvectl domain tun0 ~. >> "$LOG" 2>&1 || log "pass2 domain FAILED"
log "fix done"
}
log "dnshelper started"
LAST=""
while :; do
s=$(piactl get connectionstate 2>/dev/null || echo Unknown)
if [ "$s" != "$LAST" ]; then
LAST="$s"
log "state -> $s"
[ "$s" = Connected ] && fix &
fi
[ -f "$STOPFLAG" ] && { log "stopped"; exit 0; }
sleep 2
doneReboot gotcha (hit in the field): never log/state into
/tmpfrom a service you expect to survive reboots — with the parent dir missing, everycmd >> /tmp/...redirect fails and the command never runs, so the "fix" silently becomes a no-op after reboot while the unit keeps showing "active". Use~/.local/state/...andmkdir -pthe dir at start.
~/.config/systemd/user/pia-dns-helper.service (full file):
[Unit]
Description=PIA DNS helper: keep tun0 DNS pinned to PIA tunnel resolver (10.0.0.243) after each connect
After=default.target
[Service]
ExecStart=/home/wilsenhc/.local/bin/pia-dns-helper.sh
Restart=always
RestartSec=5
[Install]
WantedBy=default.targetInstall + enable (run as your user):
cp /tmp/pia_safe/dns-helper.sh ~/.local/bin/pia-dns-helper.sh # or write the file
chmod +x ~/.local/bin/pia-dns-helper.sh
mkdir -p ~/.config/systemd/user
# create ~/.config/systemd/user/pia-dns-helper.service with the content above
systemctl --user daemon-reload
systemctl --user enable --now pia-dns-helperGotcha from the field: a leftover
stop_dnshelperflag makes the service exit instantly and bounce forever (systemd auto-restart). Remove the flag file after stopping a previous instance.
sudo resolvectl dns tun0 10.0.0.243
sudo resolvectl domain tun0 ~.sudo sed -i 's/"overrideDNS":"pia"/"overrideDNS":"system"/' /opt/piavpn/etc/settings.json
sudo systemctl restart piavpn
grep -o '"overrideDNS":"[^"]*"' /opt/piavpn/etc/settings.jsonCaveat: if the PIA app (GUI) is running, it re-asserts its cached overrideDNS: "pia"
as soon as it reconnects to the daemon — the file edit alone is not durable. The helper
(Fix 2) works in both modes, so it's the durable layer.
Skip tun0 in the Lerd NM dispatcher (root):
sudo cp /etc/NetworkManager/dispatcher.d/99-lerd-dns /etc/NetworkManager/dispatcher.d/99-lerd-dns.bak
sudo sed -i 's|if \[ "$IFACE" = "lerd0" \]; then|if [ "$IFACE" = "lerd0" ] \|\| [ "${IFACE%%[0-9]*}" = "tun" ]; then|' \
/etc/NetworkManager/dispatcher.d/99-lerd-dnsUnmanage tun* in NM (root) — mirrors the existing wgpia* rule Lerd ships:
sudo cp /etc/NetworkManager/conf.d/wgpia.conf /etc/NetworkManager/conf.d/wgpia.conf.bak
printf '[keyfile]\nunmanaged-devices=interface-name:wgpia*,tun*\n' | sudo tee /etc/NetworkManager/conf.d/wgpia.conf
sudo systemctl restart NetworkManagerBoth reduce interference but do not stop Lerd's Go watcher (it reacts to the interface at a lower level than the NM unmanaged list), which is why the after-the-fact re-assert helper is the reliable fix.
After any reboot, three things degrade silently and must be verified before blaming the app:
-
The helper must not live in
/tmp. Checksystemctl --user is-active pia-dns-helperand that its log exists at~/.local/state/pia-dns-helper/helper.log. If anything references/tmp, it broke: on reboot/tmpis wiped, so everycmd >> /tmp/...redirect fails and the command never runs while the unit still shows "active" — the fix silently becomes a no-op. -
systemd-resolved may silently fall back to the poisoned router. At boot,
lerd-dnsisn't up yet, so resolved marks127.0.0.1:5300degraded and switches the per-link server to the second entry (192.168.88.1) — the very resolver that fakesNXDOMAINfor the PIA zone. Symptom:resolvectl statusshowsCurrent DNS Server: 192.168.88.1on the main link;getent hosts google.comworks butapi.privateinternetaccess.comfails, whiledig @127.0.0.1 -p 5300 api...answers. Fix (the router stays reachable as dnsmasq's own upstream, so it doesn't need to be a link-level server):sudo -n resolvectl dns enp1s0 127.0.0.1:5300 # drop the router from the link list resolvectl flush-caches getent hosts api.privateinternetaccess.com # expect the pinned IP curl -m 8 -sS -o /dev/null -w 'HTTP %{http_code}\n' \ https://api.privateinternetaccess.com/api/client/status # expect 200
-
Daemon API health and system DNS health are independent. PIA's daemon keeps fetching
api/client/statuswith200even while the system resolver returnsNXDOMAIN(it resolves the API over its own path). So "daemon log says200" ≠ "system DNS is fine" — check both separately before concluding.
Why you need this: connecting to a VPN can yank the network out from under the very session doing the debugging. Two pre-armed, detached scripts keep the session safe and capture evidence inside the connected window.
#!/usr/bin/env bash
# Auto-disconnects ONLY on failure modes (stuck Connecting, Interrupted/Reconnecting,
# or lifeline lost while Connected). Never interrupts a healthy connection.
# Stop with: kill "$(cat /tmp/pia_safe/breaker.pid)"
set -u
DIR=/tmp/pia_safe; LOG="$DIR/breaker.log"; PIDFILE="$DIR/breaker.pid"; FAILS=0
echo $$ > "$PIDFILE"
log(){ printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*" >> "$LOG"; }
state(){ piactl get connectionstate 2>/dev/null || echo Unknown; }
connected(){ timeout 3 bash -c 'exec 3<>/dev/tcp/1.1.1.1/443' 2>/dev/null; }
lan(){ ping -q -c1 -W2 192.168.88.1 >/dev/null 2>&1; }
log "breaker armed (pid $$)"; START=0
while :; do
s=$(state)
case "$s" in
Connecting) [ "$START" -eq 0 ] && START=$(date +%s)
[ $(( $(date +%s) - START )) -gt 45 ] && { log "stuck Connecting -> disconnect"; piactl disconnect; } ;;
Interrupted|Reconnecting) [ "$START" -eq 0 ] && START=$(date +%s)
[ $(( $(date +%s) - START )) -gt 15 ] && { log "state $s >15s -> disconnect"; piactl disconnect; } ;;
Connected) START=0
if ! connected && ! lan; then FAILS=$((FAILS+1)); [ "$FAILS" -ge 3 ] && { log "lifeline lost -> disconnect"; piactl disconnect; FAILS=0; }; else FAILS=0; fi ;;
*) START=0; FAILS=0 ;;
esac
[ -f "$DIR/stop" ] && { log "stop -> exiting"; rm -f "$PIDFILE"; exit 0; }
sleep 3
done#!/usr/bin/env bash
# Runs a probe battery at +2s and +12s after Connected; logs EVERYTHING to capture.out.
# Stop with: touch /tmp/pia_safe/stop_capture
OUT=/tmp/pia_safe/capture.out; : > "$OUT"
ts(){ date '+%H:%M:%S'; }
battery(){
{
echo "----- BATTERY @ $(ts) -----"
echo "== resolv.conf:"; grep nameserver /etc/resolv.conf
echo "== resolvectl:"; resolvectl status 2>/dev/null | grep -E 'Link |Current DNS|DNS Servers|Default Route' | head -12
echo "== routes:"; ip route show
echo "== tun0 mtu:"; cat /sys/class/net/tun0/mtu 2>/dev/null
echo "== tun0 stats:"; ip -s link show tun0 2>/dev/null | sed -n '1,8p'
echo "== dns per-link:"; resolvectl dns enp1s0 2>/dev/null; resolvectl dns tun0 2>/dev/null
echo "== ping tunnel gw:"; ping -c2 -W3 10.24.128.1 2>&1 | tail -2
echo "== ping 8.8.8.8:"; ping -q -c3 -W3 8.8.8.8 2>&1 | tail -2
echo "== ping lan gw:"; ping -q -c1 -W2 192.168.88.1 2>&1 | tail -1
echo "== http 1.1.1.1 (no DNS):"; curl -m4 -sS -o /dev/null -w 'code=%{http_code}\n' http://1.1.1.1 2>&1
echo "== dns via tunnel 10.0.0.243:"; dig @10.0.0.243 google.com +time=2 +tries=1 +short 2>&1 | head -2
echo "== dns via lerd 127.0.0.1:5300:"; dig @127.0.0.1 -p 5300 google.com +time=2 +tries=1 +short 2>&1 | head -2
echo "== dns via router:"; dig @192.168.88.1 google.com +time=2 +tries=1 +short 2>&1 | head -2
echo "== getent google.com:"; getent hosts google.com 2>&1 | head -1
} >> "$OUT" 2>&1
}
LAST=""
while :; do
s=$(piactl get connectionstate 2>/dev/null)
if [ "$s" != "$LAST" ]; then
echo "[$(ts)] STATE: ${LAST:-none} -> $s" >> "$OUT"; LAST="$s"
case "$s" in Connecting) echo "--- at Connecting ---" >> "$OUT"; ip route show >> "$OUT";;
Connected) ( sleep 2; battery ) & ( sleep 12; battery ) & ;; esac
fi
[ -f /tmp/pia_safe/stop_capture ] && exit 0
sleep 1
doneLaunch all three detached: setsid nohup bash /tmp/pia_safe/capture.sh >/dev/null 2>&1 &
(and same for breaker.sh). Keeping piactl usable (world-readable daemon socket) is what
makes the breaker able to recover even after the session's connectivity is gone.
- "Connected but no Internet" is usually DNS, not routing. Prove it with one capture:
tunnel pings/curl OK +
getent/browser fail + directdig @<tunnel-dns>answers. - Suspect UDP DNS poisoning when: a domain NXDOMAINs from the local resolver, the router, and 8.8.8.8/1.1.1.1 directly, but DNS-over-HTTPS resolves it. Only 53/UDP is tampered.
journalctl -u systemd-resolvedis the forensic tool — it logs exactly which "Bus client" set which per-link DNS/domains, and in what order (10.0.0.243→127.0.0.1:5300).- PIA Linux facts: daemon logs at
/opt/piavpn/var/daemon.log; daemon config at/opt/piavpn/etc/settings.json;piactltalks to the daemon over a world-writable socket (sopiactl disconnectstill works after connectivity is lost). The GUI re-asserts its cached settings (e.g.overrideDNS:pia) — file edits while the app runs get reverted. - Lerd owns 127.0.0.1:5300 via pasta, so any VPN that wants a local DNS resolver on 5300
(PIA's hnsd) can never bind; and Lerd's Go watcher re-claims VPN interfaces' per-link DNS
regardless of NM dispatcher patches or NM
unmanaged-devicesrules. The reliable lever is to re-assert the tunnel DNS after the fact (helper), or change the app's own setting. - Rebooting the wrong thing can take down the sandbox networking (restarting Lerd's DNS
container restarts the pasta netns). Prefer file-level/
resolvectlchanges; restart NM/Lerd only when necessary. - Containment first, connections second: always stage a breaker + capture before triggering a connect that could cut your own session.
- After reboot,
systemd-resolvedcan silently fall back to a per-link server's second entry when the first (127.0.0.1:5300) was marked degraded before the local resolver was up — theCurrent DNS Serverline inresolvectl statussays which one is live. Re-assert the link DNS list so the healthy resolver is the only option, thenresolvectl flush-caches. - A service's own API calls returning
200don't validate system DNS. PIA's daemon resolves its API independently, so it can look perfectly healthy while the system resolver is broken — verify system resolution (getent/resolvectl query) separately. - Never persist state/logs in
/tmpfrom anything meant to survive reboots. A missing parent directory makes everycmd >> /tmp/...redirect fail, which silently skips the command while the unit keeps reporting "active". Use~/.local/state/<app>/andmkdir -pit at startup.
| Value | Where to find it | Notes |
|---|---|---|
| LAN router/gateway | ip route show default |
Used for an alternate DNS probe (dig @<gw>) and breakers' LAN ping |
PIA tunnel DNS 10.0.0.243 |
pia.ovpn (route 10.0.0.243 vpn_gateway) or grep dnsServers daemon.log |
Verify while connected: dig @10.0.0.243 google.com |
| PIA zone IPs to pin | DoH query (see Fix 1) | Cloudflare-fronted; re-verify, they can change |
PIA dirs /opt/piavpn |
piactl --help, find |
Version-dependent sometimes /opt/piavpn |
Lerd dirs ~/.local/share/lerd/dnsmasq, ~/.config/lerd |
find ~ -maxdepth 4 -iname '*lerd*' |
User-level quadlet-managed |
Passwordless resolvectl |
sudo -n resolvectl dns ... (silent success = allowed) |
Needed for the helper; else prompt or sudoers |
| Scanner for the helper service user | whoami |
Unit ExecStart uses an absolute path |
# Stop + disable the DNS helper:
systemctl --user stop pia-dns-helper
systemctl --user disable pia-dns-helper
rm -f ~/.local/bin/pia-dns-helper.sh ~/.config/systemd/user/pia-dns-helper.service
# Remove the DNS zone pin:
rm -f ~/.local/share/lerd/dnsmasq/99-pia.conf
systemctl --user restart lerd-dns
# Revert root patches if desired:
sudo mv /etc/NetworkManager/conf.d/wgpia.conf.bak /etc/NetworkManager/conf.d/wgpia.conf
sudo mv /etc/NetworkManager/dispatcher.d/99-lerd-dns.bak /etc/NetworkManager/dispatcher.d/99-lerd-dns
# Reset PIA DNS to default if you ever want PIA-managed DNS again:
sudo sed -i 's/"overrideDNS":"system"/"overrideDNS":"pia"/' /opt/piavpn/etc/settings.jsonGenerated from a live debugging session — portable template; always re-verify per-machine values before applying on a new host.