Park phone DNS filtering: Private DNS fails closed, so the PC cannot host it
Some checks failed
Pre-commit checks / pre-commit (push) Has been cancelled
Shell tests / shell-tests (push) Has been cancelled
Shell tests / arch-tests (push) Has been cancelled

Measured on the Pixel 6a, twice: with private_dns_mode=hostname and the
resolver unreachable, `ping example.com` returns "unknown host". Android's
Private DNS does not fall back to the carrier's resolver -- hostname mode is
RFC 8310's strict profile, and failing to establish TLS on 853 is a hard error.

So a resolver living on this PC means "PC off" equals "phone has no internet",
not merely "no filtering". That is disqualifying for a daily driver, and it
disqualifies the whole home-hosted delivery path regardless of how well it
works while the machine is up -- which it does; it was verified end to end.

DNS_TRUSTED_DOT_HOST/IPS go back to empty, which restores the previous enforcer
behaviour byte for byte. No code revert is needed, which is exactly why the
flag was built to default empty.

Two things found while testing are worth keeping. The nftables input chain is
policy drop and admitted only ssh over wg0, so binding stunnel to the tunnel
address was not sufficient on its own: ICMP passed while TCP 853 was dropped,
which presents confusingly as "ping works but the port does not". That rule is
now added explicitly and scoped to wg0. And setup_phone_wireguard.sh automates
the client setup the tunnel needed, verifying handshake, reachability and DNS
rather than assuming success, and deleting the pushed private key afterwards.

The deeper reason the home endpoint could not work even with the tunnel up:
dns.kuhy.duckdns.org resolves to the public IP while stunnel listens only on
10.8.0.1, and with AllowedIPs=10.8.0.0/24 that routes outside the tunnel to a
closed port. Android never opened a connection at all. Split-horizon DNS does
not fix it either, since on mobile data the phone uses the carrier's resolver.

Research also ruled out the hosted-hybrid option that looked most attractive:
no provider can ingest a 185k-domain list (NextDNS none, AdGuard 1-5K, ControlD
10K), and the free tiers fail OPEN -- they stop filtering past 300k queries a
month while still resolving, which for a self-control tool is the worst failure
mode available. Details and the remaining options are in the parked doc.

The stunnel endpoint is left running deliberately: WireGuard-only, verified
unreachable from LAN and WAN, and it makes the next attempt testable
immediately. Removal commands are documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
This commit is contained in:
Krzysztof Rudnicki 2026-08-05 10:05:16 +02:00
parent da6c5427ff
commit 143d260c1c
6 changed files with 420 additions and 6 deletions

View File

@ -0,0 +1,103 @@
# Phone DNS filtering: parked 2026-08-05
Unrooted DNS-level blocking is **on hold**, not abandoned. The code is in place
and inert; what is missing is a resolver that is always reachable.
## Why it stopped
Android's Private DNS in `hostname` mode **fails closed**. Measured on the
Pixel 6a (23181JEGR08034, Android 16), twice, by pinning
`private_dns_specifier` to a host with no reachable DoT listener:
```
settings put global private_dns_mode hostname
ping example.com -> ping: unknown host example.com
```
There is no fallback to the carrier's resolver. The phone simply has no DNS.
This matches the spec rather than being a quirk: hostname mode is RFC 8310's
strict profile, and Google's own Public DNS documentation states that failure to
establish TLS on 853 is "a hard error and will result in no DNS service".
That disqualifies any design where the resolver lives on a machine that can be
switched off. The home stack (dnsmasq + stunnel on this PC) works correctly and
was verified end to end, but "PC off" means "phone has no internet", which is
not acceptable for a daily driver.
## What was left running, and why
`stunnel-dot.service` is still active on `10.8.0.1:853` (WireGuard only, with
`iifname "wg0" tcp dport 853 accept` in nftables). It is unreachable from the
LAN and the WAN — verified — so it costs nothing to leave in place, and it
makes the next attempt testable immediately.
To remove it entirely:
```bash
sudo systemctl disable --now stunnel-dot.service dot-cert-sync.timer
sudo rm -f /etc/stunnel/dot-resolver.conf /etc/systemd/system/stunnel-dot.service
rm -f ~/gitea/sites/dns.caddy && docker exec gitea-caddy caddy reload \
--config /etc/caddy/Caddyfile --adapter caddyfile
```
## What still needs deciding
An always-on resolver. Research (2026-08-05) narrowed this considerably, and
**ruled out the hybrid hosted approach that looked most attractive**:
- **No hosted provider can ingest a 185k-domain list.** NextDNS supports no
custom lists at all; AdGuard caps custom rules at 1K personal / 5K team;
ControlD caps at 10K. All are two orders of magnitude short. "Provider lists
for the bulk plus my custom entries" is therefore not available as designed —
the bulk is the part that does not fit.
- **Free tiers fail OPEN.** NextDNS and AdGuard both stop _filtering_ past
300k queries/month while continuing to resolve. For a self-control tool that
is the worst possible failure: it looks like it is working and silently is
not. A single phone exceeds 300k without difficulty.
- **Oracle Always Free reclaims idle instances** (<20% CPU over 7 days). A DNS
resolver is idle by nature, so the free tier actively conflicts with the
always-on requirement.
- **NextDNS has no profile lock** — the account password is the only barrier,
so it adds nothing on the tamper-resistance axis.
Remaining options, ranked:
1. **Cheap VPS running AdGuard Home** (Hetzner CAX11 ~€6/mo). Keeps full control
of the 185,681-domain list, always on, and everything built this session
ports unchanged. The only option that satisfies both always-on and list
control.
2. **Existing always-on hardware** (Pi, old phone, router), if any exists — same
properties as the VPS, no monthly cost, but needs a device that genuinely
stays powered.
3. **NextDNS Pro ($19.90/yr)** — accept their category blocking (porn / gambling
/ social / video, plus per-service YouTube) _instead of_ the StevenBlack list
rather than alongside it. Covers the intent, not the exact list. Cheapest
always-on path if list control is negotiable.
## What is already built and stays useful
- `python_pkg/focus_policy` — policy + hosts→domain conversion. Delivery
agnostic; feeds any of the three options above. `to_dnsmasq_conf()` targets a
self-hosted resolver, `to_domain_list()` a VpnService or an upload.
- `phone_focus_mode/dns_enforcer.sh` — `DNS_TRUSTED_DOT_HOST` is **empty**, so
behaviour is byte-identical to before. Set it to any trusted DoT hostname
(hosted or self-hosted) and the enforcer allows that one endpoint through the
853 block and pins Private DNS to it.
- `setup_dot_resolver.sh` / `setup_phone_wireguard.sh` — both verified working.
The WireGuard tunnel is up and handshaking; `--check` passes on both.
- `focus_owner` — unaffected. Device Owner app-blocking is a separate layer and
is the stronger one for tamper resistance anyway. Note that
`DISALLOW_CONFIG_PRIVATE_DNS` (API 29+, device-owner only, applies globally)
is confirmed to exist and is what would stop the phone's user editing Private
DNS in Settings — so whichever resolver is eventually chosen, the lock comes
from Device Owner, not from the provider. Pair it with `DISALLOW_CONFIG_VPN`.
## Gotcha for whoever picks this up
A DoT endpoint bound to a private address needs its **hostname to resolve to
that private address**. `dns.kuhy.duckdns.org` resolves to the public IP, and
with `AllowedIPs = 10.8.0.0/24` (split tunnel) that routes outside the tunnel to
a closed port — Android never even opened a connection. Split-horizon DNS does
not fix it either, because on mobile data the phone uses the carrier's resolver.
A hosted provider avoids this entirely; a VPS avoids it by having a real public
address that is meant to be reached.

View File

@ -0,0 +1,19 @@
{
"title": "Park unrooted DNS filtering; keep the WireGuard client setup and the firewall fix",
"objective": "Stop pursuing phone-side DNS filtering until an always-on resolver exists. Android's Private DNS fails closed, so a resolver hosted on a machine that can be switched off leaves the phone with no internet at all - which is disqualifying for a daily driver. Success is: config.sh reverted so deploying is byte-identical to before, the phone left on working DNS, the reasoning and the ruled-out options recorded, and the working artifacts (WireGuard client setup, nftables scoping) kept rather than discarded.",
"acceptance_criteria": [
"DNS_TRUSTED_DOT_HOST and DNS_TRUSTED_DOT_IPS are empty, and the enforcer harness confirms an empty allowlist plus Private-DNS-forced-off, i.e. the pre-change behaviour",
"The phone is left at private_dns_mode=off with DNS resolving",
"The fail-closed behaviour is recorded as measured on-device, not inferred",
"setup_dot_resolver.sh permits 853 on wg0 only - the input chain is policy drop and previously admitted only ssh over the tunnel, so binding to the tunnel address was not by itself sufficient",
"setup_phone_wireguard.sh installs, pushes and verifies the tunnel, and deletes the pushed private key afterwards",
"The parked doc names the specific reasons the hosted-hybrid option was ruled out, so it is not re-proposed"
],
"out_of_scope": [
"Signing up for any DNS provider",
"Provisioning a VPS",
"Tearing down the local stunnel DoT endpoint - it is WireGuard-only and unreachable from LAN and WAN, so it is left running for future testing",
"Any Device Owner provisioning or factory reset"
],
"verifier": "The stubbed-iptables harness (14 checks, section A proves the revert); shellcheck + shfmt on both scripts; setup_phone_wireguard.sh --check; adb settings get global private_dns_mode plus a ping from the phone"
}

View File

@ -0,0 +1,55 @@
{
"intent": "Stop pursuing phone-side DNS filtering until an always-on resolver exists, leaving the phone on working DNS and the config in its pre-change state, while keeping the artifacts that remain useful.",
"scope": [
"phone_focus_mode/config.sh - DNS_TRUSTED_DOT_HOST/IPS reverted to empty",
"linux_configuration/scripts/single_use/features/setup_dot_resolver.sh - permit 853 on wg0",
"linux_configuration/scripts/single_use/features/setup_phone_wireguard.sh - new",
"docs/dns-filtering-parked.md - why it stopped and what was ruled out",
"Non-goal: no provider signup, no VPS, no Device Owner provisioning",
"Non-goal: the local stunnel DoT endpoint is left running deliberately"
],
"changes": [
"Reverted DNS_TRUSTED_DOT_HOST/IPS to empty. That single change restores the previous behaviour exactly - no code revert needed, which is why the flag was built to default empty.",
"Fixed a real gap found while testing: the nftables input chain is policy drop and admitted only ssh over wg0, so binding stunnel to the tunnel address was not sufficient. ICMP passed while TCP 853 was dropped, which presents as 'ping works but the port does not'. The rule is now added explicitly, scoped to wg0.",
"Added setup_phone_wireguard.sh: installs the client, pushes the config, waits for the tunnel, deletes the pushed private key, and verifies handshake plus reachability plus DNS rather than assuming success.",
"Recorded the ruled-out options with their specific reasons so the hosted-hybrid idea is not re-proposed."
],
"verification": [
{
"command": "adb shell settings put global private_dns_mode hostname (resolver unreachable)",
"result": "pass",
"evidence": "'ping: unknown host example.com'. Reproduced twice on the Pixel 6a. Android Private DNS fails CLOSED with no carrier fallback, so a resolver on a machine that can be switched off leaves the phone with no internet. This is the measurement the whole decision rests on. Phone restored to private_dns_mode=off afterwards, pings resolving at 36ms."
},
{
"command": "bash scratchpad/test_dot.sh against the reverted config",
"result": "pass",
"evidence": "14/14. Section A confirms zero ACCEPT rules and Private DNS forced off, i.e. byte-identical to the pre-change behaviour. The live config yields an empty v4 allowlist."
},
{
"command": "setup_phone_wireguard.sh then --check",
"result": "pass",
"evidence": "Handshake from 31.61.251.198, phone reaches 10.8.0.1 over the tunnel, DNS resolves. Before the nftables fix the port test failed while ping succeeded; after it, nc to 853 connects and stunnel logs the connection."
},
{
"command": "shellcheck + shfmt on both setup scripts and config.sh",
"result": "pass",
"evidence": "Clean. An SC2024 and an unused-variable warning were fixed by restructuring (install -m 600 instead of a sudo redirect, and asserting the client config assigns the expected peer address) rather than suppressed."
},
{
"command": "ss -lnt | grep 853; systemctl is-active dnsmasq",
"result": "pass",
"evidence": "stunnel still bound to 10.8.0.1 only - never publicly bound. A split-horizon dnsmasq entry tried during debugging was removed and dnsmasq restarted; LAN DNS confirmed working afterwards."
}
],
"risks": [
"stunnel-dot.service and dot-cert-sync.timer are left running. They are WireGuard-only and verified unreachable from LAN and WAN, but they are live services with no current consumer; removal commands are in the parked doc.",
"The dns.caddy site block remains, so Caddy keeps renewing a certificate for a hostname nothing consumes. Harmless but it is a live public endpoint that did not exist before.",
"The nftables rule permitting 853 on wg0 was added at runtime and is not persisted by this repo's firewall management; it will not survive a ruleset reload unless setup_dot_resolver.sh is re-run.",
"config.sh now carries the DNS_TRUSTED_DOT_* knobs with empty values. Harmless, but a future reader could set them without realising the always-on requirement that made them unusable here - hence the parked doc."
],
"rollback": [
"The DNS_TRUSTED_DOT_* revert IS the rollback; nothing further is needed for the enforcer.",
"To remove the DoT stack entirely, follow the commands in docs/dns-filtering-parked.md.",
"After rollback validate: the enforcer harness still passes section A, and the phone still resolves DNS with private_dns_mode=off."
]
}

View File

@ -193,11 +193,23 @@ EOF
}
open_firewall() {
# Bound to WireGuard there is nothing to open: the tunnel is already
# permitted and the listener is not reachable from the LAN or the WAN.
# Binding to the WireGuard address is NOT sufficient on its own. The
# input chain has `policy drop` and admits only specific ports over wg0
# (ssh), so a DoT connection from the phone is dropped even though the
# tunnel itself is up and ICMP replies fine. That combination is
# confusing to debug -- ping works, the port does not -- so the rule is
# added explicitly, scoped to the tunnel interface.
if [[ "$BIND_ADDR" != "0.0.0.0" ]]; then
log "No firewall change needed - $BIND_ADDR is WireGuard-only."
log "No router port-forward needed either."
if command -v nft >/dev/null 2>&1 && sudo nft list table inet filter >/dev/null 2>&1; then
if sudo nft list table inet filter | grep -q "iifname \"wg0\" tcp dport $DOT_PORT"; then
log "nftables already permits $DOT_PORT on wg0"
else
sudo nft add rule inet filter input iifname wg0 tcp dport "$DOT_PORT" accept ||
log "WARNING: could not add nftables rule for $DOT_PORT on wg0"
log "Permitted TCP $DOT_PORT on wg0 only"
fi
fi
log "No router port-forward needed - $BIND_ADDR is WireGuard-only."
return
fi
if command -v nft >/dev/null 2>&1 && sudo nft list table inet filter >/dev/null 2>&1; then

View File

@ -0,0 +1,225 @@
#!/bin/bash
# ============================================================================
# Get the phone onto the WireGuard tunnel, so it can reach the DoT resolver.
#
# Why this exists:
# setup_dot_resolver.sh binds the DoT endpoint to 10.8.0.1 (WireGuard only),
# which is what keeps it from being an open resolver on the internet. That
# makes the tunnel a hard prerequisite: with it down, pinning Private DNS to
# the resolver leaves the phone with no working DNS at all. This script
# installs the client, pushes the config, and -- most importantly -- verifies
# the tunnel actually carries DNS before anything starts depending on it.
#
# What it cannot do:
# Android requires an explicit user tap to grant VPN permission and to
# import a tunnel config; there is no ADB path around that, by design. The
# script does everything either side of those taps and tells you exactly
# what to press.
#
# Usage:
# ./setup_phone_wireguard.sh [--serial <adb-serial>] [--check]
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly DEFAULT_SERIAL="23181JEGR08034"
readonly WG_PACKAGE="com.zaneschepke.wireguardautotunnel"
readonly WG_FALLBACK_PACKAGE="com.wireguard.android"
readonly SERVER_CONF="/etc/wireguard/wg0.conf"
readonly CLIENT_CONF="/etc/wireguard/clients/phone.conf"
readonly TUNNEL_ADDR="10.8.0.1"
readonly PHONE_ADDR="10.8.0.2"
readonly PUSH_PATH="/sdcard/Download/phone.conf"
SERIAL="$DEFAULT_SERIAL"
CHECK_ONLY=0
log() { printf '[phone-wg] %s\n' "$1"; }
die() {
printf '[phone-wg] ERROR: %s\n' "$1" >&2
exit 1
}
usage() {
cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS]
--serial <id> adb serial (default: $DEFAULT_SERIAL)
--check Verify the tunnel end to end, change nothing
-h, --help Show this help
EOF
exit 0
}
adb_sh() { adb -s "$SERIAL" shell "$@" 2>/dev/null | tr -d '\r'; }
require_device() {
command -v adb >/dev/null 2>&1 ||
die "adb not found; install android-tools"
adb -s "$SERIAL" get-state >/dev/null 2>&1 ||
die "device $SERIAL not connected"
}
require_server() {
sudo test -f "$SERVER_CONF" ||
die "$SERVER_CONF missing - is WireGuard set up on this host?"
sudo test -f "$CLIENT_CONF" ||
die "$CLIENT_CONF missing - no client config to import"
ip -brief addr show 2>/dev/null | grep -q "$TUNNEL_ADDR" ||
die "$TUNNEL_ADDR is not up; start it with: sudo wg-quick up wg0"
}
installed_client() {
# Echo whichever WireGuard client is present, preferring the one already
# whitelisted in phone_focus_mode.
local pkg
for pkg in "$WG_PACKAGE" "$WG_FALLBACK_PACKAGE"; do
if adb_sh "pm list packages $pkg" | grep -q .; then
printf '%s' "$pkg"
return 0
fi
done
return 1
}
install_client() {
local pkg
if pkg="$(installed_client)"; then
log "WireGuard client already installed: $pkg"
return 0
fi
# Play Store installs cannot be driven headlessly. Open the listing and
# let the user tap Install; re-running the script then proceeds.
log "No WireGuard client installed. Opening the Play Store listing..."
adb -s "$SERIAL" shell am start -a android.intent.action.VIEW \
-d "market://details?id=$WG_PACKAGE" >/dev/null 2>&1 || true
die "install WireGuard on the phone, then re-run $SCRIPT_NAME"
}
push_config() {
# The config carries a private key, so it goes to Download/ only long
# enough to be imported, and is deleted again in verify/cleanup.
log "Pushing client config to $PUSH_PATH"
local tmp
tmp="$(mktemp)"
# shellcheck disable=SC2064 # expand $tmp now, not at trap time
trap "rm -f '$tmp'" RETURN
# The redirect is to our own mktemp file, not to the root-owned source;
# install(1) makes that explicit and keeps the copy mode-0600.
sudo install -m 600 -o "$USER" "$CLIENT_CONF" "$tmp" ||
die "could not read $CLIENT_CONF"
grep -q "^Address *= *$PHONE_ADDR" "$tmp" ||
die "client config does not assign $PHONE_ADDR - wrong peer?"
adb -s "$SERIAL" push "$tmp" "$PUSH_PATH" >/dev/null ||
die "adb push failed"
}
prompt_import() {
local pkg
pkg="$(installed_client)" || die "no WireGuard client installed"
log "Opening $pkg - complete these steps on the phone:"
log " 1. '+' -> Import from file -> Download/phone.conf"
log " 2. Toggle the tunnel ON"
log " 3. Accept Android's VPN permission dialog"
adb -s "$SERIAL" shell monkey -p "$pkg" \
-c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
}
cleanup_pushed_config() {
# The pushed file contains a private key; do not leave it in Download/.
adb_sh "rm -f $PUSH_PATH" >/dev/null 2>&1 || true
log "Removed $PUSH_PATH from the phone"
}
run_check() {
local ok=0
if ! installed_client >/dev/null; then
log "FAIL no WireGuard client installed"
return 1
fi
# A handshake proves the tunnel is established from the server's side;
# the phone's own interface list is not visible without root.
local handshake
handshake="$(sudo wg show wg0 latest-handshakes 2>/dev/null | awk '{print $2}' | head -1)"
if [[ -z "$handshake" || "$handshake" == "0" ]]; then
log "FAIL no WireGuard handshake yet - is the tunnel toggled on?"
ok=1
else
log "OK handshake $(($(date +%s) - handshake))s ago"
fi
# The decisive test: not "is a VPN up" but "does the phone reach the
# resolver". Everything downstream depends on this one answer.
if adb_sh "ping -c 2 -W 3 $TUNNEL_ADDR" | grep -q " 0% packet loss"; then
log "OK phone reaches $TUNNEL_ADDR over the tunnel"
else
log "FAIL phone cannot reach $TUNNEL_ADDR"
ok=1
fi
# And that DNS actually resolves through it, which is the thing that
# broke when this was attempted without a tunnel.
if adb_sh "ping -c 2 -W 3 example.com" | grep -q " 0% packet loss"; then
log "OK DNS resolves on the phone"
else
log "FAIL DNS does not resolve on the phone"
ok=1
fi
if [[ $ok -eq 0 ]]; then
log "Tunnel healthy. Safe to set in phone_focus_mode/config.sh:"
log " DNS_TRUSTED_DOT_HOST=\"dns.kuhy.duckdns.org\""
log " DNS_TRUSTED_DOT_IPS=\"$TUNNEL_ADDR\""
fi
return "$ok"
}
main() {
require_device
if [[ $CHECK_ONLY -eq 1 ]]; then
run_check
exit $?
fi
require_server
install_client
push_config
prompt_import
log "Waiting up to 180s for the tunnel to come up..."
local deadline=$((SECONDS + 180))
while ((SECONDS < deadline)); do
if adb_sh "ping -c 1 -W 2 $TUNNEL_ADDR" | grep -q " 0% packet loss"; then
log "Tunnel is up."
break
fi
sleep 5
done
cleanup_pushed_config
run_check || {
log "Tunnel not verified. Re-run with --check after toggling it on."
exit 1
}
}
while [[ $# -gt 0 ]]; do
case $1 in
--serial)
SERIAL="$2"
shift 2
;;
--check)
CHECK_ONLY=1
shift
;;
-h | --help) usage ;;
*) die "Unknown option: $1" ;;
esac
done
main "$@"

View File

@ -242,11 +242,11 @@ export DNS_LOG="$STATE_DIR/dns_enforcer.log"
# Leave EMPTY to keep the original behaviour (Private DNS forced off, all
# 853 rejected). Do not point this at a public resolver: the whole point is
# that the resolver applies your blocklist. See python_pkg/focus_policy.
export DNS_TRUSTED_DOT_HOST="dns.kuhy.duckdns.org"
export DNS_TRUSTED_DOT_HOST=""
# Where to resolve DNS_TRUSTED_DOT_HOST when Private DNS is pinned to it.
# Chicken-and-egg: the resolver's own name must resolve without using it.
# Space-separated IPv4/IPv6 literals; leave empty to resolve at runtime.
export DNS_TRUSTED_DOT_IPS="10.8.0.1"
export DNS_TRUSTED_DOT_IPS=""
# iptables chain used exclusively by us; we flush+refill it every check.
export DNS_IPT_CHAIN="FOCUS_DNS_BLOCK"
# DoH/DoT endpoints to DROP. Well-known public resolvers used by browsers