#!/system/bin/sh
# shellcheck shell=ash
# ============================================================
# DNS enforcer for rooted Android.
#
# Why this exists:
#   /etc/hosts only works for lookups done by the *system* resolver
#   using classic DNS (UDP/TCP 53). Two bypass channels defeat it:
#     1. DNS-over-TLS (DoT, port 853) - used by Android when Private
#        DNS is "automatic" or set to a specific provider.
#     2. DNS-over-HTTPS (DoH, port 443) - used by Chrome/Brave's
#        "Use secure DNS" feature and some apps directly.
#
# Strategy:
#   1. Force `settings global private_dns_mode off` so the OS stops
#      doing DoT (there is no public DoT-by-hostname toggle).
#   2. Drop outbound traffic to a fixed list of well-known DoH/DoT
#      endpoints via iptables / ip6tables so apps' fallback logic
#      has to use the regular resolver, which consults /etc/hosts.
#
# Limitations:
#   * A custom app that hardcodes an obscure DoH IP is not caught.
#   * A root user can `iptables -F` or re-enable private DNS - but
#     this loop re-asserts every $DNS_CHECK_INTERVAL seconds and
#     leaves tamper logs.
# ============================================================

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=config.sh
. "$SCRIPT_DIR/config.sh"

PIDFILE="$STATE_DIR/dns_enforcer.pid"

mkdir -p "$STATE_DIR"
touch "$DNS_LOG"
chmod 666 "$DNS_LOG" 2>/dev/null || true

log() {
    local ts
    ts="$(date '+%Y-%m-%d %H:%M:%S')"
    echo "[$ts] $1" >> "$DNS_LOG"
}

rotate_log() {
    local lines
    lines="$(wc -l < "$DNS_LOG" 2>/dev/null || echo 0)"
    if [ "$lines" -gt 500 ]; then
        local tmp="$DNS_LOG.tmp"
        tail -n 500 "$DNS_LOG" > "$tmp"
        mv "$tmp" "$DNS_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="$(tr '\0' ' ' < "/proc/$old_pid/cmdline" 2>/dev/null)"
            if echo "$cmdline" | grep -q "dns_enforcer"; then
                echo "dns_enforcer already running (PID $old_pid)"
                exit 0
            fi
        fi
        rm -f "$PIDFILE"
    fi
    echo $$ > "$PIDFILE"
}

# ---- Private DNS ----

ensure_private_dns_off() {
    local mode
    mode="$(settings get global private_dns_mode 2>/dev/null)"
    # Possible values: "off", "opportunistic", "hostname", null (default=opportunistic)
    if [ "$mode" != "off" ]; then
        settings put global private_dns_mode off 2>/dev/null
        log "Private DNS was '$mode' - forced to 'off'"
    fi
    # Clear any pinned DoT hostname so the "hostname" mode cannot be
    # re-enabled silently by Settings UI.
    local spec
    spec="$(settings get global private_dns_specifier 2>/dev/null)"
    if [ -n "$spec" ] && [ "$spec" != "null" ]; then
        settings delete global private_dns_specifier 2>/dev/null
        log "Cleared private_dns_specifier (was '$spec')"
    fi
}

# ---- iptables chain management ----

ensure_chain() {
    local ipt="$1"
    # Create the chain if missing.
    if ! "$ipt" -L "$DNS_IPT_CHAIN" >/dev/null 2>&1; then
        "$ipt" -N "$DNS_IPT_CHAIN" 2>/dev/null || {
            log "ERROR: could not create $ipt chain $DNS_IPT_CHAIN"
            return 1
        }
        log "Created $ipt chain $DNS_IPT_CHAIN"
    fi
    # 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
}

fill_chain_v4() {
    # Flush and refill so we always converge to the intended rule set.
    iptables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1
    # Drop DoT everywhere. This is a narrow port rule - there's no legit
    # reason for arbitrary apps to talk 853/tcp on Android.
    iptables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \
        --reject-with tcp-reset 2>/dev/null || true
    iptables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \
        --reject-with icmp-port-unreachable 2>/dev/null || true

    local ip
    for ip in $DNS_DOH_IPV4; do
        [ -z "$ip" ] && continue
        [ "${ip#\#}" != "$ip" ] && continue
        # Reject 443/tcp (DoH) and 53 (classic DNS) to well-known resolvers.
        # We also block 53 so apps that try to talk to 1.1.1.1:53 directly
        # (ignoring /etc/resolv.conf) still fall back to the system resolver.
        iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \
            --reject-with tcp-reset 2>/dev/null || true
        iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \
            --reject-with icmp-port-unreachable 2>/dev/null || true
        iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 53  -j REJECT \
            --reject-with icmp-port-unreachable 2>/dev/null || true
        iptables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53  -j REJECT \
            --reject-with tcp-reset 2>/dev/null || true
    done
}

fill_chain_v6() {
    ip6tables -F "$DNS_IPT_CHAIN" 2>/dev/null || return 1
    ip6tables -A "$DNS_IPT_CHAIN" -p tcp --dport 853 -j REJECT \
        --reject-with tcp-reset 2>/dev/null || true
    ip6tables -A "$DNS_IPT_CHAIN" -p udp --dport 853 -j REJECT \
        --reject-with icmp6-port-unreachable 2>/dev/null || true

    local ip
    for ip in $DNS_DOH_IPV6; do
        [ -z "$ip" ] && continue
        [ "${ip#\#}" != "$ip" ] && continue
        ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 443 -j REJECT \
            --reject-with tcp-reset 2>/dev/null || true
        ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 443 -j REJECT \
            --reject-with icmp6-port-unreachable 2>/dev/null || true
        ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p udp --dport 53  -j REJECT \
            --reject-with icmp6-port-unreachable 2>/dev/null || true
        ip6tables -A "$DNS_IPT_CHAIN" -d "$ip" -p tcp --dport 53  -j REJECT \
            --reject-with tcp-reset 2>/dev/null || true
    done
}

enforce_iptables() {
    if command -v iptables >/dev/null 2>&1; then
        ensure_chain iptables && fill_chain_v4
    fi
    if command -v ip6tables >/dev/null 2>&1; then
        ensure_chain ip6tables && fill_chain_v6
    fi
}

cleanup() {
    # We intentionally leave the iptables chain in place on SIGTERM so
    # stopping the enforcer for maintenance does not immediately re-open
    # the DoH hole. `focus_ctl.sh dns-stop` does the explicit teardown.
    log "dns_enforcer shutting down"
    rm -f "$PIDFILE"
    exit 0
}

trap cleanup INT TERM

main() {
    acquire_lock
    log "dns_enforcer started (PID=$$)"

    # Initial arm-up
    ensure_private_dns_off
    enforce_iptables

    while true; do
        ensure_private_dns_off
        enforce_iptables
        rotate_log
        sleep "$DNS_CHECK_INTERVAL"
    done
}

main "$@"
