diff --git a/docs/superpowers/contracts/dns-enforcer-split-2026-08-17.json b/docs/superpowers/contracts/dns-enforcer-split-2026-08-17.json new file mode 100644 index 00000000..35d4377d --- /dev/null +++ b/docs/superpowers/contracts/dns-enforcer-split-2026-08-17.json @@ -0,0 +1,18 @@ +{ + "title": "split dns_enforcer.sh and give its chain code a real test", + "objective": "Move the iptables chain machinery out of dns_enforcer.sh so both files are under 250 lines, carry the new sibling in both deploy.sh lists, and replace the dead test_dns_enforcer.sh with a suite that actually exercises the moved code.", + "acceptance_criteria": [ + "verify_shell_split.sh reports all 14 top-level functions moved verbatim", + "test_dns_iptables.sh reports 20 passed, 0 failed, and fails under each of three independent mutations", + "dns_iptables.sh appears in BOTH the adb_cmd push list and the adb_root cp list in deploy.sh", + "Sourcing dns_enforcer.sh with main() disabled resolves all seven moved functions at runtime", + "shellcheck is clean on every changed shell file, standing alone, with zero suppressions", + "check_file_length.sh --all drops from 23 over-cap files to 22, with no new file over the cap" + ], + "out_of_scope": [ + "Deploying to the phone or verifying on-device behaviour — explicitly excluded by the user this session", + "Repairing test_dns_enforcer.sh, which is deleted rather than fixed", + "Any behavioural change to the chain functions themselves" + ], + "verifier": "bash phone_focus_mode/lib/tests/test_dns_iptables.sh + meta/scripts/verify_shell_split.sh + shellcheck + check_file_length.sh" +} diff --git a/docs/superpowers/evidence/dns-enforcer-split-2026-08-17.json b/docs/superpowers/evidence/dns-enforcer-split-2026-08-17.json new file mode 100644 index 00000000..8301136b --- /dev/null +++ b/docs/superpowers/evidence/dns-enforcer-split-2026-08-17.json @@ -0,0 +1,68 @@ +{ + "intent": "Bring phone_focus_mode/dns_enforcer.sh under the 250-line cap by moving its iptables chain machinery into a sibling script, and give that code its first working test.", + "scope": [ + "phone_focus_mode/dns_enforcer.sh — 325 lines, now 171", + "phone_focus_mode/dns_iptables.sh — new, 170 lines", + "phone_focus_mode/deploy.sh — both per-file lists grown by one entry", + "phone_focus_mode/lib/tests/test_dns_iptables.sh + dns_iptables_harness.sh — new tests", + "phone_focus_mode/lib/tests/test_dns_enforcer.sh — deleted" + ], + "changes": [ + "Moved ensure_chain, trusted_dot_ips, fill_chain_v4, fill_chain_v6, expected_rule_count, chain_intact and enforce_iptables into dns_iptables.sh, a sibling of the entry script rather than a lib/ member: lib/ does not exist on the phone and deploy.sh copies these files flat into $REMOTE_DIR.", + "Placed the source line by hand after the existing config.sh line. extract_shell_functions.py refuses to anchor in a #!/system/bin/sh script, and when it refuses it still writes the library but does NOT remove the functions from the source, so a naive run leaves them defined twice. The excision was done separately and confirmed by hash.", + "Added dns_iptables.sh to BOTH deploy.sh lists — the adb_cmd push staging list and the adb_root cp install list — matching how tether_iptables.sh is handled. It is correctly absent from the chmod 755 line, since a sourced library needs read, not execute.", + "Deleted test_dns_enforcer.sh. Its six assertions targeted extract_ping_ip, extract_package_uid and append_unique_line, none of which dns_enforcer.sh has ever defined; it died at its source line and had never run a single assertion since the commit that added it.", + "Wrote test_dns_iptables.sh (20 assertions over all seven moved functions) in the staging style test_tether_enforcer.sh proves works, with the shared staging and stubs in dns_iptables_harness.sh so both files stay under the cap." + ], + "verification": [ + { + "command": "bash phone_focus_mode/lib/tests/test_dns_iptables.sh", + "result": "pass", + "evidence": "20 passed, 0 failed, covering expected_rule_count (5 cases), trusted_dot_ips (4), ensure_chain (2), fill_chain_v4/v6 (3), chain_intact (3) and enforce_iptables (3)." + }, + { + "command": "mutation: expected_rule_count drops the trusted-resolver tally (n+1 -> n+0)", + "result": "fail-as-expected", + "evidence": "'FAIL: adds one ACCEPT per trusted v4 resolver address (want 7, got 6)' — the test catches the undercount that the function's own comment warns causes a rebuild every DNS_CHECK_INTERVAL seconds." + }, + { + "command": "mutation: fill_chain_v4 moves the trusted ACCEPT loop after the blanket REJECT", + "result": "fail-as-expected", + "evidence": "'FAIL: puts the trusted-resolver ACCEPT before the blanket REJECT (want ACCEPT, got REJECT)' — first-match ordering is enforced, not assumed." + }, + { + "command": "mutation: expected_rule_count counts 3 rules per DoH IP instead of 4", + "result": "fail-as-expected", + "evidence": "6 of the 20 assertions fail. Restoring the file returns 20 passed, 0 failed, so the suite is non-vacuous." + }, + { + "command": "bash meta/scripts/verify_shell_split.sh HEAD phone_focus_mode/dns_enforcer.sh phone_focus_mode/dns_enforcer.sh phone_focus_mode/dns_iptables.sh", + "result": "pass", + "evidence": "'IDENTICAL: all 14 top-level functions moved verbatim'." + }, + { + "command": "sh dns_enforcer.sh with main() disabled, then command -v each function", + "result": "pass", + "evidence": "All 11 probed functions resolve (the 7 moved plus log, rotate_log, acquire_lock, ensure_private_dns_off), proving the new source line loads the sibling at runtime and not merely at lint time." + }, + { + "command": "shellcheck on all four changed shell files", + "result": "pass", + "evidence": "Clean, each standing alone, with zero suppressions. Two SC2016 findings were resolved structurally — a [$] character class in the sed pattern and a quoted heredoc prelude — rather than disabled." + }, + { + "command": "bash meta/scripts/check_file_length.sh --all", + "result": "fail-as-expected", + "evidence": "23 files over the cap before, 22 after. dns_enforcer.sh 171, dns_iptables.sh 170, test 197, harness 133 — all under." + } + ], + "risks": [ + "UNVERIFIED ON THE PHONE. No deploy has run, by explicit instruction. If DNS enforcement misbehaves after the next deploy, dns_iptables.sh reaching the device is the first thing to check — the two deploy.sh list entries are what carry it.", + "The iptables stub models only the subset of the interface these functions touch (-L/-N/-F/-A/-I/-D/-C/-S). A real iptables failure mode outside that subset, such as a locking error, is not represented.", + "tether_enforcer.sh remains unverified on the device from the previous session, so two enforcer splits are now in flight at once." + ], + "rollback": [ + "git revert the commit; dns_enforcer.sh returns to 325 lines, dns_iptables.sh and both new test files disappear, and the two deploy.sh entries are removed together with the code they carry.", + "After rollback, run sh -n phone_focus_mode/dns_enforcer.sh and confirm the file no longer sources dns_iptables.sh, then grep deploy.sh for dns_iptables to confirm zero hits — a stale cp line pointing at a file that is no longer staged is the failure this ordering prevents." + ] +} diff --git a/phone_focus_mode/deploy.sh b/phone_focus_mode/deploy.sh index fbc6ecc6..c63bd2be 100755 --- a/phone_focus_mode/deploy.sh +++ b/phone_focus_mode/deploy.sh @@ -376,6 +376,7 @@ do_deploy() { adb_cmd push "$DEPLOY_DIR/focus_ctl.sh" "/data/local/tmp/focus_stage/focus_ctl.sh" adb_cmd push "$DEPLOY_DIR/hosts_enforcer.sh" "/data/local/tmp/focus_stage/hosts_enforcer.sh" adb_cmd push "$DEPLOY_DIR/dns_enforcer.sh" "/data/local/tmp/focus_stage/dns_enforcer.sh" + adb_cmd push "$DEPLOY_DIR/dns_iptables.sh" "/data/local/tmp/focus_stage/dns_iptables.sh" adb_cmd push "$DEPLOY_DIR/launcher_enforcer.sh" "/data/local/tmp/focus_stage/launcher_enforcer.sh" adb_cmd push "$DEPLOY_DIR/curfew_enforcer.sh" "/data/local/tmp/focus_stage/curfew_enforcer.sh" adb_cmd push "$DEPLOY_DIR/tether_enforcer.sh" "/data/local/tmp/focus_stage/tether_enforcer.sh" @@ -493,6 +494,7 @@ do_deploy() { adb_root "cp /data/local/tmp/focus_stage/focus_ctl.sh $REMOTE_DIR/focus_ctl.sh" adb_root "cp /data/local/tmp/focus_stage/hosts_enforcer.sh $REMOTE_DIR/hosts_enforcer.sh" adb_root "cp /data/local/tmp/focus_stage/dns_enforcer.sh $REMOTE_DIR/dns_enforcer.sh" + adb_root "cp /data/local/tmp/focus_stage/dns_iptables.sh $REMOTE_DIR/dns_iptables.sh" adb_root "cp /data/local/tmp/focus_stage/launcher_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh" adb_root "cp /data/local/tmp/focus_stage/curfew_enforcer.sh $REMOTE_DIR/curfew_enforcer.sh" adb_root "cp /data/local/tmp/focus_stage/tether_enforcer.sh $REMOTE_DIR/tether_enforcer.sh" diff --git a/phone_focus_mode/dns_enforcer.sh b/phone_focus_mode/dns_enforcer.sh index 7b1beb6d..eb982ef3 100755 --- a/phone_focus_mode/dns_enforcer.sh +++ b/phone_focus_mode/dns_enforcer.sh @@ -28,6 +28,12 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=config.sh . "$SCRIPT_DIR/config.sh" +# Sourced after config.sh because the chain functions read its DNS_* values. +# They also call log(), defined below; that is fine because nothing in either +# file runs until main() does. A sibling, not a lib/ member: lib/ does not +# exist on the phone, and deploy.sh copies these scripts flat into $REMOTE_DIR. +# shellcheck source=dns_iptables.sh +. "$SCRIPT_DIR/dns_iptables.sh" PIDFILE="$STATE_DIR/dns_enforcer.pid" @@ -124,113 +130,6 @@ ensure_private_dns_pinned() { # ---- 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 -} - -trusted_dot_ips() { - # Echo the trusted resolver's addresses of the given family, one per line. - # $1 is "4" or "6". Literals come from DNS_TRUSTED_DOT_IPS; anything else - # is ignored, because resolving the resolver's own name here would be - # circular once Private DNS is pinned to it. - local want="$1" ip - [ -z "${DNS_TRUSTED_DOT_HOST:-}" ] && return 0 - for ip in ${DNS_TRUSTED_DOT_IPS:-}; do - [ -z "$ip" ] && continue - [ "${ip#\#}" != "$ip" ] && continue - case "$ip" in - *:*) [ "$want" = "6" ] && printf '%s\n' "$ip" ;; - *) [ "$want" = "4" ] && printf '%s\n' "$ip" ;; - esac - done -} - -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 - # Allow the one DoT resolver we trust, BEFORE the blanket reject below. - # Order matters: iptables takes the first match, so an ACCEPT appended - # after the REJECT would never be reached. - local trusted - for trusted in $(trusted_dot_ips 4); do - iptables -A "$DNS_IPT_CHAIN" -d "$trusted" -p tcp --dport 853 \ - -j ACCEPT 2>/dev/null || true - done - # Drop DoT everywhere else. 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 - local trusted - for trusted in $(trusted_dot_ips 6); do - ip6tables -A "$DNS_IPT_CHAIN" -d "$trusted" -p tcp --dport 853 \ - -j ACCEPT 2>/dev/null || true - done - 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 -} - # ---- State-change gate ---- # # fill_chain_v4/v6 flush and reinsert ~50-100 iptables/ip6tables rules each @@ -242,59 +141,6 @@ fill_chain_v6() { # Self-healing behavior described in the header comment is preserved; only # redundant identical rebuilds are skipped. -expected_rule_count() { - # 2 fixed DoT rules (tcp+udp/853) + 4 rules per configured DoH/DNS IP, - # plus 1 ACCEPT per trusted-resolver address of this family. $1 is the - # family ("4"/"6"); the remaining arguments are the DoH/DNS IPs. - # - # This has to match fill_chain_* exactly. chain_intact() compares the - # live rule count against this number and rebuilds on any mismatch, so - # an undercount here means a rebuild every DNS_CHECK_INTERVAL seconds -- - # a silent fork storm that also drops the connection each time. - local family="$1" - shift - local n=2 ip - for ip in "$@"; do - [ -z "$ip" ] && continue - [ "${ip#\#}" != "$ip" ] && continue - n=$((n + 4)) - done - for ip in $(trusted_dot_ips "$family"); do - n=$((n + 1)) - done - echo "$n" -} - -chain_intact() { - local ipt="$1" expected="$2" actual - "$ipt" -C OUTPUT -j "$DNS_IPT_CHAIN" >/dev/null 2>&1 || return 1 - actual="$("$ipt" -S "$DNS_IPT_CHAIN" 2>/dev/null | grep -c '^-A')" - [ "$actual" = "$expected" ] -} - -enforce_iptables() { - if command -v iptables >/dev/null 2>&1; then - # Intentional word split: expected_rule_count() iterates "$@" over the - # configured IP list. This runs under /system/bin/sh on the phone, where - # arrays do not exist, so an unquoted expansion is the POSIX way to do it. - # shellcheck disable=SC2086 - if chain_intact iptables "$(expected_rule_count 4 $DNS_DOH_IPV4)"; then - : - elif ensure_chain iptables && fill_chain_v4; then - log "iptables (v4) DNS chain rebuilt (was missing/tampered)" - fi - fi - if command -v ip6tables >/dev/null 2>&1; then - # Intentional word split, as above. - # shellcheck disable=SC2086 - if chain_intact ip6tables "$(expected_rule_count 6 $DNS_DOH_IPV6)"; then - : - elif ensure_chain ip6tables && fill_chain_v6; then - log "ip6tables (v6) DNS chain rebuilt (was missing/tampered)" - fi - fi -} - cleanup() { # We intentionally leave the iptables chain in place on SIGTERM so # stopping the enforcer for maintenance does not immediately re-open diff --git a/phone_focus_mode/dns_iptables.sh b/phone_focus_mode/dns_iptables.sh new file mode 100755 index 00000000..adb3cc82 --- /dev/null +++ b/phone_focus_mode/dns_iptables.sh @@ -0,0 +1,170 @@ +#!/system/bin/sh +# shellcheck shell=ash +# ============================================================ +# dns_iptables.sh — the iptables chain half of dns_enforcer.sh. +# +# Builds and maintains the DNS_IPT_CHAIN block chain: creating it and +# pinning a single OUTPUT jump, filling it for IPv4 and IPv6, computing +# the rule count that proves it is complete, and re-asserting it when it +# has drifted. +# +# Sourced by dns_enforcer.sh after config.sh, whose DNS_* values these +# functions read. They also call log(), which dns_enforcer.sh defines; +# nothing here runs until main() calls it, so definition order is fine. +# A sibling rather than a lib/ member: lib/ does not exist on the phone, +# and deploy.sh copies these scripts flat into $REMOTE_DIR. +# ============================================================ + +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 +} +trusted_dot_ips() { + # Echo the trusted resolver's addresses of the given family, one per line. + # $1 is "4" or "6". Literals come from DNS_TRUSTED_DOT_IPS; anything else + # is ignored, because resolving the resolver's own name here would be + # circular once Private DNS is pinned to it. + local want="$1" ip + [ -z "${DNS_TRUSTED_DOT_HOST:-}" ] && return 0 + for ip in ${DNS_TRUSTED_DOT_IPS:-}; do + [ -z "$ip" ] && continue + [ "${ip#\#}" != "$ip" ] && continue + case "$ip" in + *:*) [ "$want" = "6" ] && printf '%s\n' "$ip" ;; + *) [ "$want" = "4" ] && printf '%s\n' "$ip" ;; + esac + done +} +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 + # Allow the one DoT resolver we trust, BEFORE the blanket reject below. + # Order matters: iptables takes the first match, so an ACCEPT appended + # after the REJECT would never be reached. + local trusted + for trusted in $(trusted_dot_ips 4); do + iptables -A "$DNS_IPT_CHAIN" -d "$trusted" -p tcp --dport 853 \ + -j ACCEPT 2>/dev/null || true + done + # Drop DoT everywhere else. 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 + local trusted + for trusted in $(trusted_dot_ips 6); do + ip6tables -A "$DNS_IPT_CHAIN" -d "$trusted" -p tcp --dport 853 \ + -j ACCEPT 2>/dev/null || true + done + 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 +} +expected_rule_count() { + # 2 fixed DoT rules (tcp+udp/853) + 4 rules per configured DoH/DNS IP, + # plus 1 ACCEPT per trusted-resolver address of this family. $1 is the + # family ("4"/"6"); the remaining arguments are the DoH/DNS IPs. + # + # This has to match fill_chain_* exactly. chain_intact() compares the + # live rule count against this number and rebuilds on any mismatch, so + # an undercount here means a rebuild every DNS_CHECK_INTERVAL seconds -- + # a silent fork storm that also drops the connection each time. + local family="$1" + shift + local n=2 ip + for ip in "$@"; do + [ -z "$ip" ] && continue + [ "${ip#\#}" != "$ip" ] && continue + n=$((n + 4)) + done + for ip in $(trusted_dot_ips "$family"); do + n=$((n + 1)) + done + echo "$n" +} +chain_intact() { + local ipt="$1" expected="$2" actual + "$ipt" -C OUTPUT -j "$DNS_IPT_CHAIN" >/dev/null 2>&1 || return 1 + actual="$("$ipt" -S "$DNS_IPT_CHAIN" 2>/dev/null | grep -c '^-A')" + [ "$actual" = "$expected" ] +} +enforce_iptables() { + if command -v iptables >/dev/null 2>&1; then + # Intentional word split: expected_rule_count() iterates "$@" over the + # configured IP list. This runs under /system/bin/sh on the phone, where + # arrays do not exist, so an unquoted expansion is the POSIX way to do it. + # shellcheck disable=SC2086 + if chain_intact iptables "$(expected_rule_count 4 $DNS_DOH_IPV4)"; then + : + elif ensure_chain iptables && fill_chain_v4; then + log "iptables (v4) DNS chain rebuilt (was missing/tampered)" + fi + fi + if command -v ip6tables >/dev/null 2>&1; then + # Intentional word split, as above. + # shellcheck disable=SC2086 + if chain_intact ip6tables "$(expected_rule_count 6 $DNS_DOH_IPV6)"; then + : + elif ensure_chain ip6tables && fill_chain_v6; then + log "ip6tables (v6) DNS chain rebuilt (was missing/tampered)" + fi + fi +} diff --git a/phone_focus_mode/lib/tests/dns_iptables_harness.sh b/phone_focus_mode/lib/tests/dns_iptables_harness.sh new file mode 100755 index 00000000..c852d86a --- /dev/null +++ b/phone_focus_mode/lib/tests/dns_iptables_harness.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# dns_iptables_harness.sh — staging and assertions shared by the +# dns_iptables.sh test runners. +# +# Sourced, not executed. Stages dns_enforcer.sh + dns_iptables.sh into a temp +# dir beside a fake config.sh, stubs iptables/ip6tables/settings, and exposes +# _with_subject to run a snippet against the staged copy. The caller owns PASS +# and FAIL and prints the tally. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly PHONE_DIR="${SCRIPT_DIR}/../.." + +PASS=0 +FAIL=0 + +_t_pass() { + PASS=$((PASS + 1)) + printf ' OK: %s\n' "$1" +} + +_t_fail() { + FAIL=$((FAIL + 1)) + printf ' FAIL: %s\n' "$1" +} + +# Assert two strings match, naming the expectation either way. +_t_eq() { + local want="$1" got="$2" what="$3" + if [[ "$got" == "$want" ]]; then + _t_pass "$what" + else + _t_fail "$what (want '${want}', got '${got}')" + fi +} + +RUN="$(mktemp -d)" +trap 'rm -rf "${RUN}"' EXIT +mkdir -p "${RUN}/app" "${RUN}/bin" "${RUN}/state" "${RUN}/ipt" + +cp "${PHONE_DIR}/dns_enforcer.sh" "${PHONE_DIR}/dns_iptables.sh" "${RUN}/app/" + +# --- fake config.sh in the script's own dir (dns_enforcer sources a sibling) --- +cat >"${RUN}/app/config.sh" <"${RUN}/bin/iptables" <<'STUB' +#!/usr/bin/env bash +# Models only what the functions under test inspect: a per-binary rule file +# and a flag for the OUTPUT -> chain jump. Every call is logged so tests can +# assert on ordering, which is what makes the ACCEPT-before-REJECT rule +# checkable without a real firewall. +bin="$(basename "$0")" +store="${IPT_STATE}/${bin}" +mkdir -p "$store" +rules="${store}/rules" +jump="${store}/jump" +touch "$rules" +printf '%s %s\n' "$bin" "$*" >>"${IPT_STATE}/calls.log" + +case "$1" in +-L) [[ -f "${store}/chain" ]] && exit 0 || exit 1 ;; +-N) touch "${store}/chain"; exit 0 ;; +-F) : >"$rules"; exit 0 ;; +-A) + shift + printf -- '-A %s\n' "$*" >>"$rules" + exit 0 + ;; +-I) touch "$jump"; exit 0 ;; +-D) + # Succeeds once per existing jump, so the de-dupe loop terminates. + if [[ -f "$jump" ]]; then rm -f "$jump"; exit 0; fi + exit 1 + ;; +-C) [[ -f "$jump" ]] && exit 0 || exit 1 ;; +-S) cat "$rules"; exit 0 ;; +esac +exit 0 +STUB +chmod +x "${RUN}/bin/iptables" +cp "${RUN}/bin/iptables" "${RUN}/bin/ip6tables" + +# `settings` is only reached via ensure_private_dns_*, which these tests do +# not exercise; stubbed so an accidental call cannot hit a real binary. +cat >"${RUN}/bin/settings" <<'STUB' +#!/usr/bin/env bash +echo "null" +STUB +chmod +x "${RUN}/bin/settings" + +# Build a sourceable copy: main() would otherwise loop forever. The pattern +# uses a character class for the dollar so that no literal expansion appears +# in this file, keeping the intent readable without a lint suppression. +sed 's/^main "[$]@"$/: # main disabled under test/' \ + "${RUN}/app/dns_enforcer.sh" >"${RUN}/app/under_test.sh" + +# Run a snippet with the subject sourced, under the stub PATH. Executed as a +# subprocess rather than sourced here so that $0 — and therefore SCRIPT_DIR — +# resolves to the app dir, which is how the script finds its siblings. +# +# The snippet is read from stdin rather than taken as an argument: it is code +# for the *subject* to expand, so any $VAR in it must survive this file +# untouched. A heredoc says that plainly, where a quoted argument would look +# like an expansion bug and draw SC2016. +_with_subject() { + { + cat <<'PRELUDE' +#!/usr/bin/env bash +. "$(cd "$(dirname "$0")" && pwd)/under_test.sh" +PRELUDE + cat + } >"${RUN}/app/case.sh" + PATH="${RUN}/bin:${PATH}" IPT_STATE="${RUN}/ipt" \ + TEST_DOT_HOST="${TEST_DOT_HOST:-}" TEST_DOT_IPS="${TEST_DOT_IPS:-}" \ + bash "${RUN}/app/case.sh" +} + +_reset_ipt() { + rm -rf "${RUN}/ipt" + mkdir -p "${RUN}/ipt" +} diff --git a/phone_focus_mode/lib/tests/test_dns_enforcer.sh b/phone_focus_mode/lib/tests/test_dns_enforcer.sh deleted file mode 100755 index a7597e41..00000000 --- a/phone_focus_mode/lib/tests/test_dns_enforcer.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# Unit tests for dns_enforcer.sh helper functions. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PASS=0 -FAIL=0 - -_t_pass() { - PASS=$((PASS + 1)) - printf ' OK: %s\n' "$1" -} - -_t_fail() { - FAIL=$((FAIL + 1)) - printf ' FAIL: %s\n' "$1" -} - -TMPDIR_TEST="$(mktemp -d)" -trap 'rm -rf "${TMPDIR_TEST}"' EXIT - -cat >"${TMPDIR_TEST}/config.sh" <"$block_file" -append_unique_line "$block_file" "1.2.3.4" -append_unique_line "$block_file" "1.2.3.4" -append_unique_line "$block_file" "5.6.7.8" - -if [[ "$(wc -l < "$block_file" | tr -d ' ')" == "2" ]]; then - _t_pass "append_unique_line de-duplicates values" -else - _t_fail "append_unique_line should avoid duplicates" -fi - -printf '\nResults: %d passed, %d failed\n' "$PASS" "$FAIL" -[[ "$FAIL" -eq 0 ]] diff --git a/phone_focus_mode/lib/tests/test_dns_iptables.sh b/phone_focus_mode/lib/tests/test_dns_iptables.sh new file mode 100755 index 00000000..87b4d10e --- /dev/null +++ b/phone_focus_mode/lib/tests/test_dns_iptables.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Unit tests for dns_iptables.sh — the chain half of dns_enforcer.sh. +# +# The predecessor of this file, test_dns_enforcer.sh, asserted against three +# functions that dns_enforcer.sh has never defined, so it never ran a single +# assertion. It was deleted rather than repaired. +# +# Staging, stubs and assertion helpers live in dns_iptables_harness.sh. +set -euo pipefail + +_TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=dns_iptables_harness.sh +. "${_TEST_DIR}/dns_iptables_harness.sh" + +printf '\nexpected_rule_count\n' + +_reset_ipt +# 2 fixed DoT rules + 4 per DoH IP; no trusted resolver configured. +_t_eq "10" "$(_with_subject <<'CASE' +expected_rule_count 4 1.1.1.1 8.8.8.8 +CASE +)" \ + "counts 2 fixed + 4 per DoH IP" + +_reset_ipt +_t_eq "2" "$(_with_subject <<'CASE' +expected_rule_count 4 +CASE +)" \ + "counts only the 2 fixed DoT rules when no IPs are given" + +_reset_ipt +# A commented-out entry must not be counted, or chain_intact never converges. +_t_eq "6" "$(_with_subject <<'CASE' +expected_rule_count 4 1.1.1.1 "#8.8.8.8" +CASE +)" \ + "skips a commented-out DoH IP" + +_reset_ipt +_t_eq "6" "$(_with_subject <<'CASE' +expected_rule_count 4 1.1.1.1 "" +CASE +)" \ + "skips an empty DoH IP" + +_reset_ipt +_t_eq "7" "$(TEST_DOT_HOST=dns.example.net TEST_DOT_IPS=9.9.9.9 \ + _with_subject <<'CASE' +expected_rule_count 4 1.1.1.1 +CASE +)" \ + "adds one ACCEPT per trusted v4 resolver address" + +printf '\ntrusted_dot_ips\n' + +_reset_ipt +_t_eq "" "$(_with_subject <<'CASE' +trusted_dot_ips 4 +CASE +)" \ + "yields nothing when no trusted host is set" + +_reset_ipt +_t_eq "9.9.9.9" "$(TEST_DOT_HOST=dns.example.net TEST_DOT_IPS='9.9.9.9 2620:fe::9' \ + _with_subject <<'CASE' +trusted_dot_ips 4 +CASE +)" \ + "selects only the v4 address for family 4" + +_reset_ipt +_t_eq "2620:fe::9" "$(TEST_DOT_HOST=dns.example.net TEST_DOT_IPS='9.9.9.9 2620:fe::9' \ + _with_subject <<'CASE' +trusted_dot_ips 6 +CASE +)" \ + "selects only the v6 address for family 6" + +_reset_ipt +_t_eq "" "$(TEST_DOT_HOST=dns.example.net TEST_DOT_IPS='#9.9.9.9' \ + _with_subject <<'CASE' +trusted_dot_ips 4 +CASE +)" \ + "skips a commented-out trusted address" + +printf '\nensure_chain\n' + +_reset_ipt +_t_eq "0" "$(_with_subject <<'CASE' +ensure_chain iptables >/dev/null 2>&1; echo $? +CASE +)" \ + "creates the chain and inserts the OUTPUT jump" + +_reset_ipt +_t_eq "0" "$(_with_subject <<'CASE' +ensure_chain iptables >/dev/null 2>&1 +ensure_chain iptables >/dev/null 2>&1; echo $? +CASE +)" \ + "is idempotent across repeated calls" + +printf '\nfill_chain_v4 / fill_chain_v6\n' + +_reset_ipt +_t_eq "10" "$(_with_subject <<'CASE' +ensure_chain iptables >/dev/null 2>&1 +fill_chain_v4 >/dev/null 2>&1 +iptables -S "$DNS_IPT_CHAIN" | grep -c "^-A" +CASE +)" \ + "fills v4 to exactly expected_rule_count rules" + +_reset_ipt +# The ACCEPT for the trusted resolver must precede the blanket REJECT, or +# iptables' first-match wins and the allow rule is dead. +_t_eq "ACCEPT" "$(TEST_DOT_HOST=dns.example.net TEST_DOT_IPS=9.9.9.9 \ + _with_subject <<'CASE' +ensure_chain iptables >/dev/null 2>&1 +fill_chain_v4 >/dev/null 2>&1 +iptables -S "$DNS_IPT_CHAIN" | head -1 | grep -o "ACCEPT\|REJECT" +CASE +)" \ + "puts the trusted-resolver ACCEPT before the blanket REJECT" + +_reset_ipt +_t_eq "6" "$(_with_subject <<'CASE' +ensure_chain ip6tables >/dev/null 2>&1 +fill_chain_v6 >/dev/null 2>&1 +ip6tables -S "$DNS_IPT_CHAIN" | grep -c "^-A" +CASE +)" \ + "fills v6 to 2 fixed + 4 for the single DoH v6 IP" + +printf '\nchain_intact\n' + +_reset_ipt +_t_eq "1" "$(_with_subject <<'CASE' +chain_intact iptables 10 >/dev/null 2>&1; echo $? +CASE +)" \ + "reports drift when the OUTPUT jump is missing" + +_reset_ipt +_t_eq "0" "$(_with_subject <<'CASE' +ensure_chain iptables >/dev/null 2>&1 +fill_chain_v4 >/dev/null 2>&1 +chain_intact iptables 10 >/dev/null 2>&1; echo $? +CASE +)" \ + "reports intact when jump and rule count both match" + +_reset_ipt +_t_eq "1" "$(_with_subject <<'CASE' +ensure_chain iptables >/dev/null 2>&1 +fill_chain_v4 >/dev/null 2>&1 +chain_intact iptables 99 >/dev/null 2>&1; echo $? +CASE +)" \ + "reports drift when the rule count disagrees" + +printf '\nenforce_iptables\n' + +_reset_ipt +# The whole point of the module: one call brings both families to a state +# that chain_intact then accepts, so the daemon settles instead of rebuilding. +_t_eq "0" "$(_with_subject <<'CASE' +enforce_iptables >/dev/null 2>&1 +chain_intact iptables "$(expected_rule_count 4 $DNS_DOH_IPV4)" \ +>/dev/null 2>&1; echo $? +CASE +)" \ + "converges v4 to a state chain_intact accepts" + +_reset_ipt +_t_eq "0" "$(_with_subject <<'CASE' +enforce_iptables >/dev/null 2>&1 +chain_intact ip6tables "$(expected_rule_count 6 $DNS_DOH_IPV6)" \ +>/dev/null 2>&1; echo $? +CASE +)" \ + "converges v6 to a state chain_intact accepts" + +_reset_ipt +# Second call must be a no-op at the rule level, not a rebuild. +_t_eq "10" "$(_with_subject <<'CASE' +enforce_iptables >/dev/null 2>&1 +enforce_iptables >/dev/null 2>&1 +iptables -S "$DNS_IPT_CHAIN" | grep -c "^-A" +CASE +)" \ + "stays at the same rule count when run twice" + +printf '\nResults: %d passed, %d failed\n' "$PASS" "$FAIL" +[[ "$FAIL" -eq 0 ]]