Renames this repo's markdown onto README.md / CLAUDE*.md / DOCS*.md / TODO*.md so a future session can tell a live task from a finished record without reading the file to find out. TODO files carry the literal "REMOVE ME AFTER FINISH" and are deleted when the work lands. Enforced by the md-naming pre-commit hook and CI workflow, both delegating to the shared gate in kuhyx/utils so this repo's idea of a valid name cannot drift from every other repo's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A47aX5DJwmA3QFn3pkEWi7
5.2 KiB
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:
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:
- 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.
- 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.
- 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_HOSTis 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;--checkpasses on both.focus_owner— unaffected. Device Owner app-blocking is a separate layer and is the stronger one for tamper resistance anyway. Note thatDISALLOW_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 withDISALLOW_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.