#!/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 "$@"
