#!/system/bin/sh # shellcheck shell=ash # ============================================================ # Hosts file enforcer for rooted Android. # # Mirrors the PC-side guard in linux_configuration/hosts/ but # for /system/etc/hosts on Android, which has no chattr, no # systemd, and where /system is read-only. # # Strategy (defense in depth): # 1. Canonical hosts file lives at HOSTS_CANONICAL and is # chattr +i (best-effort; ignored if kernel/fs rejects). # 2. Bind-mount HOSTS_CANONICAL read-only over HOSTS_TARGET so # that even `echo > /system/etc/hosts` fails for everyone, # including root-in-a-terminal-app, without re-mounting. # 3. A watchdog loop re-asserts the bind mount and verifies # sha256 every HOSTS_CHECK_INTERVAL seconds. # # Known limitation: a user with root *and* willingness to run # `umount /system/etc/hosts; mount -o remount,rw /system ...` # can still bypass this. Making it "impossible without USB" is # not achievable on a rooted phone with a local terminal. # This enforcer closes the one-liner gap and adds logging so # tampering leaves an audit trail. # ============================================================ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPT_DIR/config.sh" PIDFILE="$STATE_DIR/hosts_enforcer.pid" mkdir -p "$STATE_DIR" "$(dirname "$HOSTS_CANONICAL")" touch "$HOSTS_LOG" chmod 666 "$HOSTS_LOG" 2>/dev/null || true log() { local ts ts="$(date '+%Y-%m-%d %H:%M:%S')" echo "[$ts] $1" >> "$HOSTS_LOG" } rotate_log() { local lines lines="$(wc -l < "$HOSTS_LOG" 2>/dev/null || echo 0)" if [ "$lines" -gt 500 ]; then local tmp="$HOSTS_LOG.tmp" tail -n 500 "$HOSTS_LOG" > "$tmp" mv "$tmp" "$HOSTS_LOG" fi } acquire_lock() { if [ -f "$PIDFILE" ]; then local old_pid old_pid="$(cat "$PIDFILE")" if kill -0 "$old_pid" 2>/dev/null; then local cmdline cmdline="$(cat "/proc/$old_pid/cmdline" 2>/dev/null | tr '\0' ' ')" if echo "$cmdline" | grep -q "hosts_enforcer"; then echo "hosts_enforcer already running (PID $old_pid)" exit 0 fi fi rm -f "$PIDFILE" fi echo $$ > "$PIDFILE" } sha256_of() { # Android's toybox has sha256sum; fall back to md5sum if missing. if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" 2>/dev/null | awk '{print $1}' else md5sum "$1" 2>/dev/null | awk '{print $1}' fi } # ---- Workout-aware canonical selection ---- # When workout_detector.sh writes "1" to $WORKOUT_ACTIVE_FILE, switch to # the YouTube-relaxed canonical. Any other value (including missing file or # unreadable) falls back to the full-block canonical (fail-closed). workout_active() { [ -f "$WORKOUT_ACTIVE_FILE" ] || return 1 local v v="$(cat "$WORKOUT_ACTIVE_FILE" 2>/dev/null | tr -d '[:space:]')" [ "$v" = "1" ] } current_canonical() { if workout_active && [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then echo "$HOSTS_CANONICAL_WORKOUT" else echo "$HOSTS_CANONICAL" fi } current_sha_file() { if workout_active && [ -f "$HOSTS_SHA_FILE_WORKOUT" ]; then echo "$HOSTS_SHA_FILE_WORKOUT" else echo "$HOSTS_SHA_FILE" fi } is_bind_mounted_correctly() { # Android devices often already have /system/etc/hosts as its own mount # point (OEM overlay / f2fs block). A mere "path is in /proc/self/mounts" # check is not enough - we must verify the mounted content matches our # currently-active canonical by hash (which depends on workout state). if [ ! -f "$HOSTS_TARGET" ]; then return 1 fi local target_hash canonical_hash canonical canonical="$(current_canonical)" target_hash="$(sha256_of "$HOSTS_TARGET")" canonical_hash="$(sha256_of "$canonical")" [ -n "$target_hash" ] && [ "$target_hash" = "$canonical_hash" ] } unmount_existing_hosts_mount() { # If anything else is already mounted on /system/etc/hosts (OEM overlay # or a previous failed bind), unmount it so we can take its place. local attempts=0 while grep -qE "[[:space:]]${HOSTS_TARGET}[[:space:]]" /proc/self/mounts 2>/dev/null; do if [ "$attempts" -ge 5 ]; then log "Could not fully unmount $HOSTS_TARGET after 5 attempts" return 1 fi umount "$HOSTS_TARGET" 2>/dev/null \ || umount -l "$HOSTS_TARGET" 2>/dev/null \ || break attempts=$((attempts + 1)) done return 0 } make_target_writable_once() { # /system is usually mounted read-only. Make it rw just long enough # to overwrite HOSTS_TARGET with the canonical content, then remount ro. local system_mount canonical canonical="$(current_canonical)" system_mount="$(awk '$2=="/system"{print $2; exit}' /proc/self/mounts)" if [ -z "$system_mount" ]; then system_mount="/system" fi mount -o remount,rw "$system_mount" 2>/dev/null || true chattr -i "$HOSTS_TARGET" 2>/dev/null || true cp "$canonical" "$HOSTS_TARGET" 2>/dev/null || true chmod 644 "$HOSTS_TARGET" 2>/dev/null || true chattr +i "$HOSTS_TARGET" 2>/dev/null || true mount -o remount,ro "$system_mount" 2>/dev/null || true } assert_bind_mount() { if is_bind_mounted_correctly; then return 0 fi # Something is in the way (OEM overlay or previous partial mount). unmount_existing_hosts_mount local canonical canonical="$(current_canonical)" # Try plain bind mount - no remount-rw of /system needed. if mount --bind "$canonical" "$HOSTS_TARGET" 2>/dev/null; then mount -o remount,ro,bind "$HOSTS_TARGET" 2>/dev/null || true if is_bind_mounted_correctly; then log "Bind-mounted $canonical over $HOSTS_TARGET" sync_magisk_module "$canonical" return 0 fi log "Bind mount reported success but target still mismatches - unmounting" umount "$HOSTS_TARGET" 2>/dev/null || true fi # Bind failed - fall back to direct overwrite of /system/etc/hosts. log "Bind mount failed - falling back to direct overwrite" make_target_writable_once if is_bind_mounted_correctly; then sync_magisk_module "$canonical" return 0 fi return 1 } # Keep the Magisk Systemless Hosts module file in sync with the currently # active canonical so that a future reboot mounts the correct variant. We # only rewrite when the contents differ (cheap hash compare) to avoid # touching the module dir on every loop iteration. sync_magisk_module() { local canonical="$1" [ -n "$canonical" ] && [ -f "$canonical" ] || return 0 local module_dir module_dir="$(dirname "$(dirname "$(dirname "$HOSTS_MAGISK_MODULE_FILE")")")" [ -d "$module_dir" ] || return 0 local module_hash canonical_hash module_hash="$(sha256_of "$HOSTS_MAGISK_MODULE_FILE")" canonical_hash="$(sha256_of "$canonical")" if [ "$module_hash" != "$canonical_hash" ]; then # Drop +i on the module dir + file long enough to update, then re-lock. chattr -i "$module_dir" 2>/dev/null || true chattr -i "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || true cp "$canonical" "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || return 0 chmod 644 "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || true chattr +i "$HOSTS_MAGISK_MODULE_FILE" 2>/dev/null || true log "Synced Magisk module hosts to $(basename "$canonical")" fi # Always re-assert dir-level lock at the end so a partial earlier failure # leaves us in the locked state on the next iteration. protect_magisk_module } # Defense against the user disabling the Magisk Systemless Hosts module via # the Magisk app UI. The "Disable" / "Remove" buttons work by creating a # marker file inside the module directory: # # /data/adb/modules/hosts/disable # disable on next reboot # /data/adb/modules/hosts/remove # uninstall on next reboot # # We do TWO things every poll cycle: # 1. Delete those markers if they appeared (so a reboot still loads us) # 2. Set chattr +i on the module directory itself so the Magisk app cannot # create those markers in the first place. The +i flag on a directory # blocks adding/removing/renaming entries inside it, which is exactly # what `touch disable` does. Files already inside remain mutable, so # sync_magisk_module() can still rewrite the hosts file (it briefly # drops the lock above to handle the rare case where it must). # # To intentionally disable the module for maintenance, run from a root # shell on the phone: # focus_ctl.sh hosts-stop # chattr -i /data/adb/modules/hosts # touch /data/adb/modules/hosts/disable protect_magisk_module() { local module_dir module_dir="$(dirname "$(dirname "$(dirname "$HOSTS_MAGISK_MODULE_FILE")")")" [ -d "$module_dir" ] || return 0 # Step 1: nuke any disable/remove markers that may have been created # since the last poll. We have to chattr -i first because either of the # two locks below may already be in effect. local removed=0 for marker in disable remove update; do local f="$module_dir/$marker" if [ -e "$f" ]; then chattr -i "$module_dir" 2>/dev/null || true chattr -i "$f" 2>/dev/null || true if rm -f "$f" 2>/dev/null; then removed=$((removed + 1)) log "TAMPER: removed Magisk module marker $f" fi fi done # Step 2: re-lock the module dir so the Magisk app cannot recreate them # via its UI. Best-effort - if the kernel/fs rejects +i, the runtime # delete loop above is still our safety net. chattr +i "$module_dir" 2>/dev/null || true 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). chmod 644 "$HOSTS_CANONICAL" 2>/dev/null || true chattr +i "$HOSTS_CANONICAL" 2>/dev/null || true if [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then chmod 644 "$HOSTS_CANONICAL_WORKOUT" 2>/dev/null || true chattr +i "$HOSTS_CANONICAL_WORKOUT" 2>/dev/null || true 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)" sha_file="$(current_sha_file)" if [ ! -f "$canonical" ]; then log "ERROR: canonical hosts missing at $canonical" return 1 fi local expected expected="$(cat "$sha_file" 2>/dev/null)" if [ -z "$expected" ]; then expected="$(sha256_of "$canonical")" echo "$expected" > "$sha_file" chmod 644 "$sha_file" 2>/dev/null || true chattr +i "$sha_file" 2>/dev/null || true fi # Canonical integrity check local actual_canonical actual_canonical="$(sha256_of "$canonical")" if [ "$actual_canonical" != "$expected" ]; then log "TAMPER: $(basename "$canonical") hash mismatch (expected $expected, got $actual_canonical)" # We cannot fix the canonical from here - it is the source of truth. # Just log and continue; deploy.sh must re-push. return 1 fi # Live target integrity check. Mismatch can mean either tampering OR a # legitimate workout-state transition that swapped the active canonical. # In both cases the fix is the same: re-assert the bind mount with the # currently-active canonical. local actual_target actual_target="$(sha256_of "$HOSTS_TARGET")" if [ "$actual_target" != "$expected" ]; then if workout_active; then log "Workout-active swap: $HOSTS_TARGET differs from workout canonical - re-mounting" else log "TAMPER or post-workout swap: $HOSTS_TARGET hash mismatch - restoring" fi assert_bind_mount flush_browser_dns_caches fi } cleanup() { log "hosts_enforcer shutting down" rm -f "$PIDFILE" exit 0 } trap cleanup INT TERM main() { acquire_lock log "hosts_enforcer started (PID=$$)" ensure_canonical_immutable 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 sha256_of "$HOSTS_CANONICAL" > "$HOSTS_SHA_FILE" chmod 644 "$HOSTS_SHA_FILE" 2>/dev/null || true chattr +i "$HOSTS_SHA_FILE" 2>/dev/null || true fi if [ ! -f "$HOSTS_SHA_FILE_WORKOUT" ] && [ -f "$HOSTS_CANONICAL_WORKOUT" ]; then sha256_of "$HOSTS_CANONICAL_WORKOUT" > "$HOSTS_SHA_FILE_WORKOUT" chmod 644 "$HOSTS_SHA_FILE_WORKOUT" 2>/dev/null || true chattr +i "$HOSTS_SHA_FILE_WORKOUT" 2>/dev/null || true fi while true; do verify_and_restore protect_magisk_module rotate_log sleep "$HOSTS_CHECK_INTERVAL" done } main "$@"