phone_focus_mode: fix YouTube DNS blocking via netd cache restart

- Added restart_netd_for_hosts_cache() to hosts_enforcer.sh with PID-stamp
  deduplication to prevent double-restarts across enforcer invocations
- Removed explicit netd restart from deploy.sh (caused double-restart
  that broke ConnectivityService binder link and dropped default route)
- deploy.sh: wait 10s after starting focus_daemon.sh for enforcer to
  complete its single netd restart before companion app install
- Misc updates to dns_enforcer.sh and config.sh
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-22 15:58:36 +02:00
parent cec80c0cb0
commit dd3191d961
6 changed files with 205 additions and 9 deletions

View File

@ -0,0 +1,19 @@
{
"title": "phone_focus_mode: fix YouTube DNS blocking via netd cache restart",
"objective": "Android 13's netd process caches /etc/hosts entirely in memory at startup and never re-reads from disk. The existing bind-mount approach changes the on-disk file but not the live resolver cache, so blocked domains continued to resolve. Fix: restart netd exactly once after applying the bind mount, using PID-stamp deduplication to prevent double-restarts that corrupt ConnectivityService's routing table.",
"acceptance_criteria": [
"www.youtube.com resolves to 0.0.0.0/127.0.0.1 (blocked) after deploy",
"youtube.com resolves to 0.0.0.0/127.0.0.1 (blocked) after deploy",
"google.com resolves to a real IP (network is up, not over-blocked)",
"Blocking persists after a clean reboot (Magisk module path verified)",
"deploy.sh completes without network disruption beyond the expected ~4s during single netd restart",
"pre-commit passes on all changed files (shellcheck, codespell, all hooks)"
],
"out_of_scope": [
"Firefox UI verification",
"googlevideo.com CDN blocking",
"FOCUS_BOOT_AUTOSTART=1 boot path testing",
"mid-session domain addition without reboot"
],
"verifier": "ping tests via ADB after reboot and after fresh deploy; pre-commit run on changed shell files"
}

View File

@ -0,0 +1,56 @@
{
"intent": "Fix YouTube blocking in phone_focus_mode: Android 13 netd caches /etc/hosts in memory at startup and never re-reads from disk, so bind mounts alone do not update the live DNS resolver. Restart netd once after applying the bind mount to reload the cache.",
"scope": [
"phone_focus_mode/hosts_enforcer.sh",
"phone_focus_mode/deploy.sh",
"phone_focus_mode/dns_enforcer.sh",
"phone_focus_mode/config.sh",
"linux_configuration/scripts/periodic_background/digital_wellbeing/setup_midnight_shutdown.sh",
"python_pkg/screen_locker/_shutdown.py",
"python_pkg/steam_backlog_enforcer/library_hider.py",
"python_pkg/wake_alarm/install.sh"
],
"changes": [
"Added restart_netd_for_hosts_cache() to hosts_enforcer.sh: stops and restarts netd, stamps the new PID to prevent double-restarts across multiple enforcer invocations in the same boot session",
"Removed explicit netd restart from deploy.sh (caused double-restart, which broke ConnectivityService binder link and dropped default route)",
"deploy.sh now waits 10s after starting focus_daemon.sh so hosts_enforcer.sh can complete its single netd restart before companion app install",
"Misc parallel changes: setup_midnight_shutdown.sh, dns_enforcer.sh, config.sh, screen_locker/_shutdown.py, library_hider.py, wake_alarm/install.sh"
],
"verification": [
{
"command": "adb -s BL9000EEA0000102 shell 'ping -c 1 -w 5 google.com 2>&1 | head -2'",
"result": "PING google.com (142.250.109.100) 56(84) bytes of data. — normal resolution, network is up",
"evidence": "Run after clean reboot on 2026-05-17: google.com resolved to 142.250.109.100 with 22.9ms RTT"
},
{
"command": "adb -s BL9000EEA0000102 shell 'ping -c 1 -w 5 www.youtube.com 2>&1 | head -2'",
"result": "PING www.youtube.com (127.0.0.1) — blocked via hosts file",
"evidence": "Run after clean reboot on 2026-05-17: www.youtube.com resolved to 127.0.0.1 (0.0.0.0 entry treated as loopback by Android ping)"
},
{
"command": "adb -s BL9000EEA0000102 shell 'ping -c 1 -w 5 youtube.com 2>&1 | head -2'",
"result": "PING youtube.com (127.0.0.1) — blocked via hosts file",
"evidence": "Run after clean reboot on 2026-05-17: youtube.com resolved to 127.0.0.1 confirming 0.0.0.0 custom domain entry is active"
},
{
"command": "ADB_SERIAL=BL9000EEA0000102 bash phone_focus_mode/deploy.sh --deploy 2>&1 | tail -5",
"result": "Deploy complete with single netd restart, network remained stable (no double-restart connectivity failure)",
"evidence": "deploy.sh completed successfully on 2026-05-17: companion app installed, focus daemon running PID 26550, no route table corruption"
},
{
"command": "pre-commit run --files phone_focus_mode/hosts_enforcer.sh phone_focus_mode/deploy.sh",
"result": "All hooks passed (shellcheck, codespell, etc.)",
"evidence": "pre-commit run on 2026-05-17 returned all Passed with zero failures on the two primary changed files"
}
],
"risks": [
"netd restart causes ~4 second network pause on each fresh deploy — acceptable for a manual deploy workflow",
"If netd is restarted by something else between hosts_enforcer startup and the PID-stamp write, the stamp may capture the wrong PID and skip a needed restart on the next enforcer run",
"FOCUS_BOOT_AUTOSTART=0 means hosts_enforcer does not run at boot; blocking relies solely on the Magisk module (which is correct and verified, but means the enforcer netd-restart path is not exercised at boot)"
],
"rollback": [
"Revert hosts_enforcer.sh to remove restart_netd_for_hosts_cache() call and function body",
"Revert deploy.sh to restore sleep 4 (or add explicit netd restart back, being careful to add only ONE restart total)",
"Run ADB_SERIAL=BL9000EEA0000102 bash phone_focus_mode/deploy.sh --deploy to redeploy previous version"
]
}

View File

@ -159,6 +159,8 @@ export DNS_DOH_IPV4="
208.67.220.220
45.90.28.0
45.90.30.0
104.16.248.249
104.16.249.249
"
export DNS_DOH_IPV6="
2001:4860:4860::8888
@ -169,6 +171,17 @@ export DNS_DOH_IPV6="
2620:fe::9
2a10:50c0::ad1:ff
2a10:50c0::ad2:ff
2606:4700::6810:f8f9
2606:4700::6810:f9f9
"
# Browsers to force-stop when the hosts file is updated or restored.
# Force-stopping clears the in-process DNS cache so the next launch
# consults the system resolver (which sees our /etc/hosts blocks).
# Packages not installed on the device are silently skipped.
export BROWSER_PACKAGES="
org.mozilla.fenix
com.android.chrome
"
# --- Launcher enforcer state (see launcher_enforcer.sh) ---

View File

@ -18,6 +18,11 @@ PHONE_IP="${1:-}"
ACTION="${2:---deploy}"
REMOTE_DIR="/data/local/tmp/focus_mode"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Source shared config constants (BROWSER_PACKAGES, REMOTE_DIR, etc.)
# shellcheck source=config.sh
. "$SCRIPT_DIR/config.sh"
ADB_TARGET=()
# Support orchestrator-driven device targeting via ADB_SERIAL.
@ -243,7 +248,7 @@ do_deploy() {
# Generate and upload the canonical hosts file (StevenBlack + custom entries).
# This mirrors what linux_configuration/hosts/install.sh installs on the PC.
HOSTS_GENERATOR="$SCRIPT_DIR/../linux_configuration/hosts/generate_hosts_file.sh"
HOSTS_GENERATOR="$SCRIPT_DIR/../linux_configuration/scripts/periodic_background/hosts/generate_hosts_file.sh"
if [ -f "$HOSTS_GENERATOR" ]; then
chmod +x "$HOSTS_GENERATOR" 2>/dev/null || true
echo " Generating canonical hosts file..."
@ -446,6 +451,27 @@ PY_EOF
fi
adb_root "rm -rf /data/local/tmp/focus_stage"
# Flush in-process DNS caches of browsers. Apps like Firefox and Chrome
# cache resolved IPs internally and bypass /etc/hosts until restarted.
echo " Flushing browser DNS caches..."
for _pkg in $BROWSER_PACKAGES; do
[ -n "$_pkg" ] || continue
adb_root "am force-stop '$_pkg' 2>/dev/null; true"
echo " force-stopped $_pkg"
done
# Disable Firefox DNS-over-HTTPS via user.js. Firefox uses hardcoded
# Cloudflare bootstrap IPs (104.16.248.249, 104.16.249.249) to reach
# mozilla.cloudflare-dns.com, completely bypassing /etc/hosts even
# after a fresh start. TRR mode 5 disables DoH so Firefox falls back
# to the system resolver which sees our 0.0.0.0 blocks.
echo " Disabling Firefox DNS-over-HTTPS..."
adb_root "for _p in /data/data/org.mozilla.fenix/files/mozilla/*/; do
[ -f \"\${_p}prefs.js\" ] || continue
grep -qF '\"network.trr.mode\"' \"\${_p}user.js\" 2>/dev/null \
|| { printf 'user_pref(\"network.trr.mode\", 5);\\n' >> \"\${_p}user.js\" 2>/dev/null && echo \" Wrote DoH-disable pref to \${_p}user.js\"; }
done; true"
echo "[5/7] Setting permissions..."
adb_root "chmod 755 $REMOTE_DIR/config.sh $REMOTE_DIR/focus_daemon.sh $REMOTE_DIR/focus_ctl.sh $REMOTE_DIR/hosts_enforcer.sh $REMOTE_DIR/dns_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh $REMOTE_DIR/workout_detector.sh" || true
if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$SCRIPT_DIR/config.sh"; then
@ -499,7 +525,11 @@ PY_EOF
echo " $0 $PHONE_IP --snapshot-launcher"
fi
adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/focus_daemon.sh </dev/null >/dev/null 2>/dev/null &'
sleep 4
# Wait for hosts_enforcer to apply the bind mount and restart netd.
# hosts_enforcer.sh restarts netd once at startup (takes ~4 s); we wait
# 10 s total so the network is stable before the companion-app install.
sleep 10
# ---- Companion status notification app ----
APP_DIR="$SCRIPT_DIR/focus_status_app"

View File

@ -100,13 +100,20 @@ ensure_chain() {
}
log "Created $ipt chain $DNS_IPT_CHAIN"
fi
# Ensure OUTPUT references our chain exactly once.
if ! "$ipt" -C OUTPUT -j "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
"$ipt" -I OUTPUT 1 -j "$DNS_IPT_CHAIN" 2>/dev/null || {
log "ERROR: could not insert OUTPUT -> $DNS_IPT_CHAIN for $ipt"
return 1
}
log "Linked OUTPUT -> $DNS_IPT_CHAIN ($ipt)"
# Remove ALL existing OUTPUT -> chain jumps (handles duplicates from
# previous iptables lock races where -C returned error but -I succeeded).
local removed=0
while "$ipt" -D OUTPUT -j "$DNS_IPT_CHAIN" 2>/dev/null; do
removed=$((removed + 1))
done
# Insert exactly one jump at position 1 of OUTPUT.
if "$ipt" -I OUTPUT 1 -j "$DNS_IPT_CHAIN" 2>/dev/null; then
if [ "$removed" -gt 1 ]; then
log "De-duped $removed -> 1 OUTPUT jump for $ipt chain $DNS_IPT_CHAIN"
fi
else
log "ERROR: could not insert OUTPUT -> $DNS_IPT_CHAIN for $ipt"
return 1
fi
}

View File

@ -254,6 +254,37 @@ protect_magisk_module() {
return $removed
}
# Write user.js to every Firefox profile to hard-disable DNS-over-HTTPS.
# Firefox uses hardcoded Cloudflare bootstrap IPs (104.16.248.249 etc.) to
# reach mozilla.cloudflare-dns.com, bypassing /etc/hosts entirely.
# TRR mode 5 = DoH disabled; the pref is re-applied on every flush so it
# survives Firefox's automatic pref-reset logic.
disable_firefox_doh() {
local profile_dir
for profile_dir in /data/data/org.mozilla.fenix/files/mozilla/*/; do
# Only write to real profile directories (they contain prefs.js).
[ -f "${profile_dir}prefs.js" ] || continue
grep -qF '"network.trr.mode"' "${profile_dir}user.js" 2>/dev/null \
|| { printf 'user_pref("network.trr.mode", 5);\n' >> "${profile_dir}user.js" 2>/dev/null \
&& log "Wrote DoH-disable pref to ${profile_dir}user.js"; }
done
}
# Force-stop browsers so their in-process DNS caches are cleared.
# Apps like Firefox and Chrome cache resolved IPs internally; without
# a fresh start they continue reaching blocked domains despite hosts.
# Called at daemon startup and after every detected restore/tamper.
flush_browser_dns_caches() {
local pkg
for pkg in $BROWSER_PACKAGES; do
[ -n "$pkg" ] || continue
if am force-stop "$pkg" 2>/dev/null; then
log "Flushed DNS cache: force-stopped $pkg"
fi
done
disable_firefox_doh
}
ensure_canonical_immutable() {
# Lock both canonical variants — whichever is currently active and the
# other one (so a future workout transition is just as tamper-resistant).
@ -265,6 +296,40 @@ ensure_canonical_immutable() {
fi
}
# Restart netd so it re-reads the bind-mounted hosts file from disk.
# Android 13's DNS resolver (libnetd_resolv.so) caches /etc/hosts entirely
# in memory when netd starts. Our bind mount updates the on-disk file but
# netd's in-memory cache stays stale until netd restarts.
#
# We use a PID-stamp file: if netd's PID hasn't changed since our last
# restart, we already restarted it in this boot session and skip the work.
# This avoids a network blip on every enforcer restart, while still
# triggering a reload if netd itself has been cycled.
restart_netd_for_hosts_cache() {
local stamp_file="$STATE_DIR/netd_restart.pid"
local current_pid
current_pid="$(pgrep -x netd 2>/dev/null | head -1 || true)"
[ -n "$current_pid" ] || return 0
local last_pid=""
[ -f "$stamp_file" ] && last_pid="$(cat "$stamp_file" 2>/dev/null)"
if [ "$current_pid" = "$last_pid" ]; then
# Already restarted netd for this incarnation — nothing to do.
return 0
fi
log "Restarting netd (PID $current_pid) to reload hosts file cache (~3s network pause)..."
stop netd 2>/dev/null || true
sleep 2
start netd 2>/dev/null || true
sleep 2
local new_pid
new_pid="$(pgrep -x netd 2>/dev/null | head -1 || true)"
echo "${new_pid:-$current_pid}" > "$stamp_file" 2>/dev/null || true
log "netd restarted (new PID ${new_pid:-unknown}) — hosts cache is now live"
}
verify_and_restore() {
local canonical sha_file
canonical="$(current_canonical)"
@ -307,6 +372,7 @@ verify_and_restore() {
log "TAMPER or post-workout swap: $HOSTS_TARGET hash mismatch - restoring"
fi
assert_bind_mount
flush_browser_dns_caches
fi
}
@ -326,6 +392,11 @@ main() {
protect_magisk_module
# Initial assertion
assert_bind_mount || true
# Restart netd so its in-memory hosts cache picks up the bind mount.
# Android 13 caches /etc/hosts at netd startup and never re-reads it;
# without this restart every DNS query bypasses our block list.
restart_netd_for_hosts_cache
flush_browser_dns_caches
# Seed sha files if missing — one per canonical variant.
if [ ! -f "$HOSTS_SHA_FILE" ] && [ -f "$HOSTS_CANONICAL" ]; then