diff --git a/docs/superpowers/contracts/curfew-net-split-2026-08-17.json b/docs/superpowers/contracts/curfew-net-split-2026-08-17.json new file mode 100644 index 00000000..54611b76 --- /dev/null +++ b/docs/superpowers/contracts/curfew-net-split-2026-08-17.json @@ -0,0 +1,21 @@ +{ + "title": "Split curfew_enforcer.sh's network layer into a tested sibling", + "objective": "Take curfew_enforcer.sh from 367 lines to under the repo's 250-line cap by moving its nine network-curfew functions into curfew_net.sh, and cover that library at 100%. The per-UID iptables allow-list decides which apps can reach the network overnight; a wrong rule order silently blocks everything or blocks nothing.", + "acceptance_criteria": [ + "bash meta/scripts/check_file_length.sh --all no longer lists phone_focus_mode/curfew_enforcer.sh, and no new file is over the cap.", + "bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_curfew_net.sh curfew_net.sh exits 0 at its default 100% minimum.", + "Every assertion is mutation-proofed with zero survivors, including mutations that invert the REJECT, cut off root, and pass non-numeric uids to iptables.", + "The rule ORDER is asserted, not just rule presence: every ACCEPT must precede the 10000-19999 REJECT.", + "meta/scripts/verify_shell_split.sh reports every function verbatim, except any rewritten for measurability — for which the emitted rule sequence must be proven identical instead.", + "curfew_net.sh is added to BOTH of deploy.sh's hardcoded lists (the push list and the cp list), as a flat sibling matching dns_iptables.sh and tether_iptables.sh — not under lib/, which deploy.sh does not create on the device.", + "shellcheck passes on each touched file individually, with no -x and no disable directive.", + "sh -n passes on both phone-side scripts, which run under ash, not bash." + ], + "out_of_scope": [ + "Deploying to the phone or verifying on-device; there is no device in this session.", + "Splitting the grayscale or DND layers out of curfew_enforcer.sh, which is already under the cap without the net layer.", + "Adding curfew_net.sh to deploy.sh's chmod list: it is sourced, not executed, matching how dns_iptables.sh and tether_iptables.sh are handled.", + "Changing what the curfew blocks or allows." + ], + "verifier": "bash phone_focus_mode/lib/tests/test_curfew_net.sh (expects 41 passed, 0 failed), plus bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_curfew_net.sh curfew_net.sh at 100.00%, plus the mutation run at 17 killed / 0 survived, plus bash meta/scripts/check_file_length.sh --all showing 20 files, plus grep for curfew_net in both deploy.sh lists, plus pre-commit run --files on the touched files." +} diff --git a/docs/superpowers/evidence/curfew-net-split-2026-08-17.json b/docs/superpowers/evidence/curfew-net-split-2026-08-17.json new file mode 100644 index 00000000..f58fc66b --- /dev/null +++ b/docs/superpowers/evidence/curfew-net-split-2026-08-17.json @@ -0,0 +1,83 @@ +{ + "intent": "Bring curfew_enforcer.sh under the 250-line cap by extracting its network-curfew layer into curfew_net.sh, and cover that layer at 100% — it had no test, and it is the code that decides which apps can reach the network overnight.", + "scope": [ + "phone_focus_mode/curfew_enforcer.sh - 367 lines down to 244", + "phone_focus_mode/curfew_net.sh - new: night_uids, ensure_net_chain, fill_net_chain, iptw, refresh_uid_cache, rebuild_net_from_cache, net_hold, apply_net, teardown_net", + "phone_focus_mode/deploy.sh - the new sibling added to both hardcoded lists", + "phone_focus_mode/lib/tests/curfew_net_harness.sh - new: iptables/ip6tables/pm stubs", + "phone_focus_mode/lib/tests/curfew_net_cases_chain.sh - new: chain-construction assertions", + "phone_focus_mode/lib/tests/curfew_net_cases_lifecycle.sh - new: lifecycle assertions", + "phone_focus_mode/lib/tests/test_curfew_net.sh - new: the entry point", + "Non-goal: any device deploy. The split is proven with stubs, hashes and output comparison instead." + ], + "changes": [ + "Moved the nine network functions with meta/scripts/extract_shell_functions.py. As expected for a #!/system/bin/sh script with no set -e anchor, the tool moved the functions and warned that the source line had to be placed by hand; it was placed after the existing config.sh source, which defines the globals these functions read.", + "Placed the library as a FLAT SIBLING (phone_focus_mode/curfew_net.sh), not under lib/. deploy.sh pushes phone scripts into one flat directory, so a lib/ path would not exist on the device; dns_iptables.sh and tether_iptables.sh sit beside their enforcers for the same reason. This was caught by reading the deploy lists before committing, not after.", + "Added curfew_net.sh to both of deploy.sh's hardcoded lists: the adb_cmd push list and the adb_root cp list. It is deliberately NOT added to the chmod 755 list, matching dns_iptables.sh and tether_iptables.sh — sourced files do not need the executable bit on the device.", + "Left NET_BUILT in the entry script: both files write it, so it is a genuine shared global and neither file assigns something it never reads.", + "Rewrote two `while ... done < file` loops as pipes. A trailing `done < file` is a line kcov instruments but bash never reports, and those two lines alone held the file at 96.77%. Neither loop reads stdin internally, so the pipe's subshell is immaterial — unlike the pull_apks case in backup_capture.sh, where that same change would reintroduce a known bug.", + "Added stubs on PATH rather than function mocks: unlike the monitor probes, these functions invoke iptables, ip6tables and pm as real binaries, so the process boundary is where the fake has to sit. The stub logs every call in order, which is what makes rule ordering assertable." + ], + "verification": [ + { + "command": "bash phone_focus_mode/lib/tests/test_curfew_net.sh", + "result": "pass", + "evidence": "Results: 41 passed, 0 failed. There was no test for this code before." + }, + { + "command": "bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_curfew_net.sh curfew_net.sh", + "result": "pass", + "evidence": "curfew_net.sh: 60/60 lines = 100.00%, exit 0 at the default 100% minimum. First measured run was 96.77%." + }, + { + "command": "mutation run: 17 mutations of the network behaviour, each required to fail the suite", + "result": "pass", + "evidence": "17 killed, 0 survived. Includes flipping the 10000-19999 REJECT to ACCEPT (which would disable the whole cut-off), cutting off root, moving the OUTPUT jump off position 1, and passing non-numeric uids to iptables." + }, + { + "command": "rule-order assertion inside the suite", + "result": "pass", + "evidence": "The REJECT line index is asserted greater than the last ACCEPT line index. Presence assertions alone would pass with the order reversed, which would block every whitelisted app." + }, + { + "command": "bash meta/scripts/verify_shell_split.sh 61be666 phone_focus_mode/curfew_enforcer.sh phone_focus_mode/curfew_enforcer.sh phone_focus_mode/curfew_net.sh", + "result": "pass", + "evidence": "Reports night_uids and fill_net_chain as differing, which is correct: those are the two functions whose loops were rewritten. Every other function hashes identical." + }, + { + "command": "behavioural comparison of the two rewritten functions against their pre-split bodies on identical inputs", + "result": "pass", + "evidence": "night_uids emits '10123 10456' from both. fill_net_chain emits an identical 11-rule sequence from both, diffed byte-for-byte including ordering — so the hash differences above carry no behaviour change." + }, + { + "command": "shellcheck on each touched file individually (no -x)", + "result": "pass", + "evidence": "All seven clean standalone. Two SC2034 findings in the lifecycle cases were fixed by reading the values back rather than by a disable." + }, + { + "command": "sh -n phone_focus_mode/curfew_enforcer.sh; sh -n phone_focus_mode/curfew_net.sh", + "result": "pass", + "evidence": "Both parse under ash, which is what actually runs them on the device. The enforcer is a daemon loop and is not safe to execute here, so this plus the sourced-library tests is the substitute for running it." + }, + { + "command": "grep curfew_net phone_focus_mode/deploy.sh", + "result": "pass", + "evidence": "Two hits: the push list at line 382 and the cp list at line 501. Both lists updated, which is the step the brief flags as easy to miss." + }, + { + "command": "bash meta/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "20 over-cap files, down from 21. curfew_enforcer.sh 244, curfew_net.sh 156, and the three test files 170/174/121 — all under the cap." + } + ], + "risks": [ + "UNVERIFIED ON DEVICE. This joins tether_enforcer.sh and dns_enforcer.sh as an enforcer split with passing tests and no deploy behind it. If the night curfew's network layer misbehaves after the next deploy, this is one of three places to look.", + "The library is a new file that must reach the device. If the deploy runs from a stale checkout, or a partial push drops curfew_net.sh, curfew_enforcer.sh will fail to source it and exit immediately rather than running with the net layer disabled. The net layer defaults to off, so the blast radius of it being missing is the whole enforcer, not just the net layer.", + "The two rewritten loops now run their bodies in a pipe subshell. Neither assigns a variable the caller reads, so nothing is lost today, but a future edit that adds such an assignment inside either loop would silently not take effect." + ], + "rollback": [ + "git revert the commit. curfew_enforcer.sh returns to a single 367-line file, over the cap and untested, and deploy.sh stops pushing curfew_net.sh.", + "After rollback, run sh -n phone_focus_mode/curfew_enforcer.sh expecting exit 0, and confirm grep curfew_net phone_focus_mode/deploy.sh returns nothing.", + "A rollback AFTER a deploy would leave a stale curfew_net.sh on the device; it is inert once nothing sources it, but remove it from $REMOTE_DIR to avoid confusing a later audit." + ] +} diff --git a/phone_focus_mode/curfew_enforcer.sh b/phone_focus_mode/curfew_enforcer.sh index d5c4a6bb..8a2dd9da 100755 --- a/phone_focus_mode/curfew_enforcer.sh +++ b/phone_focus_mode/curfew_enforcer.sh @@ -29,6 +29,8 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=config.sh . "$SCRIPT_DIR/config.sh" +# shellcheck source=curfew_net.sh +. "$SCRIPT_DIR/curfew_net.sh" PIDFILE="$STATE_DIR/curfew_enforcer.pid" # Snapshot of the user's pre-curfew display/DND state, captured on entry and @@ -157,72 +159,9 @@ restore_dnd() { # ---- Layer 3: per-UID network allow-list (default OFF) ---- -# Resolve the UIDs of the night-whitelisted packages. Apps not installed are -# silently skipped. Output: one numeric UID per line. -night_uids() { - local plist="$STATE_DIR/night_whitelist.txt" - [ -f "$plist" ] || return 0 - # `pm list packages -U` lines look like: "package:com.foo uid:10123" - local map="$STATE_DIR/uid_map.txt" - pm list packages -U 2>/dev/null \ - | sed 's/^package://' > "$map" - while IFS= read -r pkg; do - [ -z "$pkg" ] && continue - awk -v p="$pkg" '$1 == p { sub(/uid:/,"",$2); print $2 }' "$map" - done < "$plist" - rm -f "$map" -} -ensure_net_chain() { - local ipt="$1" - if ! iptw "$ipt" -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then - iptw "$ipt" -N "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1 - fi - # De-dupe and pin exactly one OUTPUT jump at position 1. - while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done - iptw "$ipt" -I OUTPUT 1 -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1 -} -fill_net_chain() { - local ipt="$1" reject="$2" - iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1 - # Always-allowed plumbing: loopback, established flows, the OS itself, the - # daemon/ADB (root + shell), and DNS (apps resolve via netd, a different - # uid, so allow port 53 broadly or every lookup fails under the cut-off). - iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -o lo -j ACCEPT 2>/dev/null || true - iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true - iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 0 -j ACCEPT 2>/dev/null || true - iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 1000 -j ACCEPT 2>/dev/null || true - iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 2000 -j ACCEPT 2>/dev/null || true - iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p udp --dport 53 -j ACCEPT 2>/dev/null || true - iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true - # Allow each whitelisted app UID, read from the cache (refreshed once per - # main tick). Reading the cache instead of calling night_uids() here keeps - # the fast watchdog fork-free (no `pm list packages` on every rebuild). - local uid - if [ -f "$CURFEW_NET_UID_CACHE" ]; then - while IFS= read -r uid; do - case "$uid" in ''|*[!0-9]*) continue ;; esac - iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner "$uid" -j ACCEPT 2>/dev/null || true - done < "$CURFEW_NET_UID_CACHE" - fi - # Cut off every remaining ordinary APP uid (10000-19999 = user-0 app range). - # Scoped to the app range so kernel/system sockets (no owner / low uids) are - # never touched — far safer than a blanket default-DROP. - iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 10000-19999 -j REJECT \ - --reject-with "$reject" 2>/dev/null || true -} -# Run iptables/ip6tables with a 2s xtables lock-wait. Android's netd runs its -# own concurrent `iptables-restore`; without -w our calls silently fail the -# instant netd holds the lock (proven on-device: partial 19-rule chains and -# multi-second outages). -w queues for the lock so our calls actually land. -# iptables 1.8.7 legacy supports it. $1 = binary (iptables/ip6tables). -iptw() { - local bin="$1" - shift - "$bin" -w 2 "$@" -} # Set to 1 once the net chain has been built in this process. Used purely to # tell an *anomalous* mid-curfew disappearance (something outside this script @@ -231,72 +170,10 @@ iptw() { # false-flags its own initial build. NET_BUILT="" -# Refresh the cached UID list (one `pm list packages -U` fork). Called once per -# main tick so the fast watchdog can rebuild from the cache without forking. -refresh_uid_cache() { - if night_uids > "$CURFEW_NET_UID_CACHE.tmp" 2>/dev/null; then - mv "$CURFEW_NET_UID_CACHE.tmp" "$CURFEW_NET_UID_CACHE" 2>/dev/null || true - fi -} -# Rebuild the chain from cache for whichever iptables variants exist. No pm fork. -rebuild_net_from_cache() { - if command -v iptables >/dev/null 2>&1; then - ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable - fi - if command -v ip6tables >/dev/null 2>&1; then - ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable - fi -} -# Fast watchdog: for `total` seconds, every CURFEW_NET_REASSERT_INTERVAL check -# whether netd wiped our chain and, if so, re-pin it from cache. Replaces the -# plain inter-tick sleep while curfew is active so the leak window drops from -# the full 5s tick to <=1s. Echoes the number of rebuilds it performed. -net_hold() { - local total="$1" elapsed=0 rebuilds=0 step="${CURFEW_NET_REASSERT_INTERVAL:-1}" - while [ "$elapsed" -lt "$total" ]; do - sleep "$step" - elapsed=$((elapsed + step)) - if command -v iptables >/dev/null 2>&1 \ - && ! iptw iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then - rebuild_net_from_cache - rebuilds=$((rebuilds + 1)) - fi - done - echo "$rebuilds" -} -apply_net() { - [ "${CURFEW_NET_ENABLED:-0}" = "1" ] || return 0 - refresh_uid_cache - # Discriminating probe: if we already built the chain on a prior tick but it - # is gone now, an external actor wiped it (Android netd rewriting the filter - # table, or a manual flush during debugging). Log each disappearance so the - # live test reads "flush + self-heal" vs "dead process" directly, instead of - # inferring it from log silence. - if [ -n "$NET_BUILT" ] && command -v iptables >/dev/null 2>&1 \ - && ! iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then - log "net chain $CURFEW_NET_IPT_CHAIN vanished since last tick - rebuilding (external flush?)" - fi - if command -v iptables >/dev/null 2>&1; then - ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable - fi - if command -v ip6tables >/dev/null 2>&1; then - ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable - fi - NET_BUILT=1 -} -teardown_net() { - local ipt - for ipt in iptables ip6tables; do - command -v "$ipt" >/dev/null 2>&1 || continue - while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done - iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true - iptw "$ipt" -X "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true - done -} # ---- Apply / revert orchestration ---- diff --git a/phone_focus_mode/curfew_net.sh b/phone_focus_mode/curfew_net.sh new file mode 100755 index 00000000..a4f208ce --- /dev/null +++ b/phone_focus_mode/curfew_net.sh @@ -0,0 +1,156 @@ +#!/system/bin/sh +# shellcheck shell=ash +# curfew_net.sh — the network layer of the night curfew: the per-UID +# iptables allow-list that cuts every app outside $NIGHT_WHITELIST off the +# network while the curfew is open. Default OFF ($CURFEW_NET_ENABLED). +# +# A flat sibling, not lib/: deploy.sh pushes phone scripts into one +# directory, which is why dns_iptables.sh and tether_iptables.sh live +# beside their enforcers too. +# +# Sourced by curfew_enforcer.sh, which owns log(), the config.sh globals +# these read ($CURFEW_NET_*, $NIGHT_WHITELIST, $STATE_DIR) and $NET_BUILT, +# the build-once flag both files write. + +# Resolve the UIDs of the night-whitelisted packages. Apps not installed are +# silently skipped. Output: one numeric UID per line. +night_uids() { + local plist="$STATE_DIR/night_whitelist.txt" + [ -f "$plist" ] || return 0 + # `pm list packages -U` lines look like: "package:com.foo uid:10123" + local map="$STATE_DIR/uid_map.txt" + pm list packages -U 2>/dev/null \ + | sed 's/^package://' > "$map" + # Piped rather than redirected on `done`: kcov instruments a trailing + # `done < file` as a line bash never reports, which alone held this file + # below 100%. The loop only writes to stdout, so running it in the pipe's + # subshell changes nothing — unlike the pull_apks case in + # backup_capture.sh, nothing inside here consumes stdin. + cat "$plist" | while IFS= read -r pkg; do + [ -z "$pkg" ] && continue + awk -v p="$pkg" '$1 == p { sub(/uid:/,"",$2); print $2 }' "$map" + done + rm -f "$map" +} + +ensure_net_chain() { + local ipt="$1" + if ! iptw "$ipt" -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then + iptw "$ipt" -N "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1 + fi + # De-dupe and pin exactly one OUTPUT jump at position 1. + while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done + iptw "$ipt" -I OUTPUT 1 -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1 +} + +fill_net_chain() { + local ipt="$1" reject="$2" + iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1 + # Always-allowed plumbing: loopback, established flows, the OS itself, the + # daemon/ADB (root + shell), and DNS (apps resolve via netd, a different + # uid, so allow port 53 broadly or every lookup fails under the cut-off). + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -o lo -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 0 -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 1000 -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 2000 -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p udp --dport 53 -j ACCEPT 2>/dev/null || true + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true + # Allow each whitelisted app UID, read from the cache (refreshed once per + # main tick). Reading the cache instead of calling night_uids() here keeps + # the fast watchdog fork-free (no `pm list packages` on every rebuild). + local uid + if [ -f "$CURFEW_NET_UID_CACHE" ]; then + # Piped for the same reason as night_uids above: a trailing + # `done < file` is a line kcov counts and bash never reports. The + # loop's only effect is the iptw calls, which are external commands, + # so the pipe's subshell is immaterial here. + cat "$CURFEW_NET_UID_CACHE" | while IFS= read -r uid; do + case "$uid" in ''|*[!0-9]*) continue ;; esac + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner "$uid" -j ACCEPT 2>/dev/null || true + done + fi + # Cut off every remaining ordinary APP uid (10000-19999 = user-0 app range). + # Scoped to the app range so kernel/system sockets (no owner / low uids) are + # never touched — far safer than a blanket default-DROP. + iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 10000-19999 -j REJECT \ + --reject-with "$reject" 2>/dev/null || true +} + +# Run iptables/ip6tables with a 2s xtables lock-wait. Android's netd runs its +# own concurrent `iptables-restore`; without -w our calls silently fail the +# instant netd holds the lock (proven on-device: partial 19-rule chains and +# multi-second outages). -w queues for the lock so our calls actually land. +# iptables 1.8.7 legacy supports it. $1 = binary (iptables/ip6tables). +iptw() { + local bin="$1" + shift + "$bin" -w 2 "$@" +} + +# Refresh the cached UID list (one `pm list packages -U` fork). Called once per +# main tick so the fast watchdog can rebuild from the cache without forking. +refresh_uid_cache() { + if night_uids > "$CURFEW_NET_UID_CACHE.tmp" 2>/dev/null; then + mv "$CURFEW_NET_UID_CACHE.tmp" "$CURFEW_NET_UID_CACHE" 2>/dev/null || true + fi +} + +# Rebuild the chain from cache for whichever iptables variants exist. No pm fork. +rebuild_net_from_cache() { + if command -v iptables >/dev/null 2>&1; then + ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable + fi + if command -v ip6tables >/dev/null 2>&1; then + ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable + fi +} + +# Fast watchdog: for `total` seconds, every CURFEW_NET_REASSERT_INTERVAL check +# whether netd wiped our chain and, if so, re-pin it from cache. Replaces the +# plain inter-tick sleep while curfew is active so the leak window drops from +# the full 5s tick to <=1s. Echoes the number of rebuilds it performed. +net_hold() { + local total="$1" elapsed=0 rebuilds=0 step="${CURFEW_NET_REASSERT_INTERVAL:-1}" + while [ "$elapsed" -lt "$total" ]; do + sleep "$step" + elapsed=$((elapsed + step)) + if command -v iptables >/dev/null 2>&1 \ + && ! iptw iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then + rebuild_net_from_cache + rebuilds=$((rebuilds + 1)) + fi + done + echo "$rebuilds" +} + +apply_net() { + [ "${CURFEW_NET_ENABLED:-0}" = "1" ] || return 0 + refresh_uid_cache + # Discriminating probe: if we already built the chain on a prior tick but it + # is gone now, an external actor wiped it (Android netd rewriting the filter + # table, or a manual flush during debugging). Log each disappearance so the + # live test reads "flush + self-heal" vs "dead process" directly, instead of + # inferring it from log silence. + if [ -n "$NET_BUILT" ] && command -v iptables >/dev/null 2>&1 \ + && ! iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then + log "net chain $CURFEW_NET_IPT_CHAIN vanished since last tick - rebuilding (external flush?)" + fi + if command -v iptables >/dev/null 2>&1; then + ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable + fi + if command -v ip6tables >/dev/null 2>&1; then + ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable + fi + NET_BUILT=1 +} + +teardown_net() { + local ipt + for ipt in iptables ip6tables; do + command -v "$ipt" >/dev/null 2>&1 || continue + while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done + iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true + iptw "$ipt" -X "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true + done +} diff --git a/phone_focus_mode/deploy.sh b/phone_focus_mode/deploy.sh index c63bd2be..23fcabf2 100755 --- a/phone_focus_mode/deploy.sh +++ b/phone_focus_mode/deploy.sh @@ -379,6 +379,7 @@ do_deploy() { 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/curfew_net.sh" "/data/local/tmp/focus_stage/curfew_net.sh" adb_cmd push "$DEPLOY_DIR/tether_enforcer.sh" "/data/local/tmp/focus_stage/tether_enforcer.sh" adb_cmd push "$DEPLOY_DIR/tether_iptables.sh" "/data/local/tmp/focus_stage/tether_iptables.sh" adb_cmd push "$DEPLOY_DIR/workout_detector.sh" "/data/local/tmp/focus_stage/workout_detector.sh" @@ -497,6 +498,7 @@ do_deploy() { 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/curfew_net.sh $REMOTE_DIR/curfew_net.sh" adb_root "cp /data/local/tmp/focus_stage/tether_enforcer.sh $REMOTE_DIR/tether_enforcer.sh" adb_root "cp /data/local/tmp/focus_stage/tether_iptables.sh $REMOTE_DIR/tether_iptables.sh" adb_root "cp /data/local/tmp/focus_stage/workout_detector.sh $REMOTE_DIR/workout_detector.sh" diff --git a/phone_focus_mode/lib/tests/curfew_net_cases_chain.sh b/phone_focus_mode/lib/tests/curfew_net_cases_chain.sh new file mode 100755 index 00000000..9295a20c --- /dev/null +++ b/phone_focus_mode/lib/tests/curfew_net_cases_chain.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# lib/tests/curfew_net_cases_chain.sh — assertions for the chain-construction +# half of curfew_net.sh: night_uids, ensure_net_chain and fill_net_chain. +# +# Sourced by test_curfew_net.sh after curfew_net_harness.sh, which owns the +# stubs, the subject and the PASS/FAIL counters this file adds to. Split out +# to keep every test file under the repo's 250-line cap. +set -euo pipefail + +# --- night_uids ------------------------------------------------------------- + +_t_eq "" "$(night_uids)" "night_uids is empty when the whitelist file is absent" + +printf 'com.foo\ncom.bar\n\ncom.absent\n' >"${STATE_DIR}/night_whitelist.txt" +printf 'com.foo uid:10123\ncom.bar uid:10456\ncom.other uid:10999\n' >"${PM_FIXTURE}" + +_t_eq "10123 10456" "$(night_uids | tr '\n' ' ' | sed 's/ $//')" \ + "night_uids resolves only the whitelisted packages" + +if [[ ! -f "${STATE_DIR}/uid_map.txt" ]]; then + _t_pass "night_uids cleans up its temporary uid map" +else + _t_fail "night_uids left uid_map.txt behind" +fi + +# --- ensure_net_chain ------------------------------------------------------- + +_reset_ipt +if ensure_net_chain iptables; then + _t_pass "ensure_net_chain creates a missing chain" +else + _t_fail "ensure_net_chain should succeed when it can create the chain" +fi + +case "$(_calls)" in + *"-N FOCUS_CURFEW_NET"*) _t_pass "ensure_net_chain creates the named chain" ;; + *) _t_fail "ensure_net_chain did not create the chain" ;; +esac + +case "$(_calls)" in + *"-I OUTPUT 1 -j FOCUS_CURFEW_NET"*) _t_pass "ensure_net_chain pins the OUTPUT jump at position 1" ;; + *) _t_fail "ensure_net_chain did not pin the jump at position 1" ;; +esac + +# Chain creation failing must be reported, not silently continued past. +_reset_ipt +_fail_op N +if ! ensure_net_chain iptables; then + _t_pass "ensure_net_chain fails when the chain cannot be created" +else + _t_fail "ensure_net_chain should fail when -N fails" +fi +_clear_fail N + +# The jump insert failing likewise. +_reset_ipt +_fail_op I +if ! ensure_net_chain iptables; then + _t_pass "ensure_net_chain fails when the OUTPUT jump cannot be inserted" +else + _t_fail "ensure_net_chain should fail when -I fails" +fi +_clear_fail I + +# Stale duplicate jumps left by a lock race are removed before the new one. +_reset_ipt +_seed_jumps iptables 3 +ensure_net_chain iptables +# Four calls for three jumps: the loop is `while -D succeeds`, so the fourth +# is the one that fails and ends it. Asserting on the count rather than on +# the final state is what would catch the loop terminating early. +_t_eq "4" "$(_calls | grep -c -- "-D OUTPUT -j FOCUS_CURFEW_NET")" \ + "ensure_net_chain deletes every stale OUTPUT jump" +_t_eq "1" "$(cat "${RUN}/ipt/iptables/jumps")" \ + "ensure_net_chain leaves exactly one OUTPUT jump" + +# An existing chain is reused rather than recreated. +_reset_ipt +_seed_jumps iptables 0 +ensure_net_chain iptables +if _calls | grep -q -- "-N FOCUS_CURFEW_NET"; then + _t_fail "ensure_net_chain recreated a chain that already existed" +else + _t_pass "ensure_net_chain reuses an existing chain" +fi + +# --- fill_net_chain --------------------------------------------------------- + +_reset_ipt +printf '10123\n10456\n' >"${CURFEW_NET_UID_CACHE}" +fill_net_chain iptables icmp-port-unreachable + +rules="$(_rules_of iptables)" + +case "${rules}" in + *"-o lo -j ACCEPT"*) _t_pass "fill_net_chain always allows loopback" ;; + *) _t_fail "fill_net_chain must allow loopback" ;; +esac + +case "${rules}" in + *"ESTABLISHED,RELATED -j ACCEPT"*) _t_pass "fill_net_chain allows established flows" ;; + *) _t_fail "fill_net_chain must allow established flows" ;; +esac + +for uid in 0 1000 2000; do + case "${rules}" in + *"--uid-owner ${uid} -j ACCEPT"*) _t_pass "fill_net_chain allows uid ${uid}" ;; + *) _t_fail "fill_net_chain must allow uid ${uid}" ;; + esac +done + +# DNS on both protocols: apps resolve via netd under a different uid, so +# without these every lookup fails under the cut-off. +_t_eq "2" "$(printf '%s\n' "${rules}" | grep -c -- "--dport 53 -j ACCEPT")" \ + "fill_net_chain allows DNS over both udp and tcp" + +for uid in 10123 10456; do + case "${rules}" in + *"--uid-owner ${uid} -j ACCEPT"*) _t_pass "fill_net_chain allows whitelisted uid ${uid}" ;; + *) _t_fail "fill_net_chain must allow whitelisted uid ${uid}" ;; + esac +done + +case "${rules}" in + *"--uid-owner 10000-19999 -j REJECT"*) _t_pass "fill_net_chain rejects the remaining app uid range" ;; + *) _t_fail "fill_net_chain must reject the remaining app range" ;; +esac + +# Ordering is the whole point: a REJECT before the ACCEPTs blocks everything. +reject_line="$(printf '%s\n' "${rules}" | grep -n -- "10000-19999" | cut -d: -f1)" +last_accept="$(printf '%s\n' "${rules}" | grep -n -- "-j ACCEPT" | tail -1 | cut -d: -f1)" +if [[ "${reject_line}" -gt "${last_accept}" ]]; then + _t_pass "fill_net_chain puts the REJECT after every ACCEPT" +else + _t_fail "fill_net_chain REJECT at ${reject_line} precedes an ACCEPT at ${last_accept}" +fi + +# The reject-with argument differs per protocol and must be passed through. +_reset_ipt +fill_net_chain ip6tables icmp6-port-unreachable +case "$(_rules_of ip6tables)" in + *"--reject-with icmp6-port-unreachable"*) _t_pass "fill_net_chain passes the v6 reject type through" ;; + *) _t_fail "fill_net_chain must pass the caller's reject type" ;; +esac + +# A non-numeric cache line must be skipped, not passed to iptables. +_reset_ipt +printf '10123\nnot-a-uid\n\n10456\n' >"${CURFEW_NET_UID_CACHE}" +fill_net_chain iptables icmp-port-unreachable +if _rules_of iptables | grep -q "not-a-uid"; then + _t_fail "fill_net_chain passed a non-numeric uid to iptables" +else + _t_pass "fill_net_chain skips non-numeric cache entries" +fi + +# A missing cache is not fatal: the plumbing rules must still be laid down. +_reset_ipt +rm -f "${CURFEW_NET_UID_CACHE}" +fill_net_chain iptables icmp-port-unreachable +case "$(_rules_of iptables)" in + *"10000-19999 -j REJECT"*) _t_pass "fill_net_chain still cuts off apps with no uid cache" ;; + *) _t_fail "fill_net_chain must lay down the REJECT even with no cache" ;; +esac + +# A failing flush aborts the fill rather than layering onto stale rules. +_reset_ipt +_fail_op F +if ! fill_net_chain iptables icmp-port-unreachable; then + _t_pass "fill_net_chain fails when the chain cannot be flushed" +else + _t_fail "fill_net_chain should fail when -F fails" +fi +_clear_fail F diff --git a/phone_focus_mode/lib/tests/curfew_net_cases_lifecycle.sh b/phone_focus_mode/lib/tests/curfew_net_cases_lifecycle.sh new file mode 100755 index 00000000..c6be0d60 --- /dev/null +++ b/phone_focus_mode/lib/tests/curfew_net_cases_lifecycle.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# lib/tests/curfew_net_cases_lifecycle.sh — assertions for the lifecycle half +# of curfew_net.sh: iptw, refresh_uid_cache, rebuild_net_from_cache, net_hold, +# apply_net and teardown_net. +# +# Sourced by test_curfew_net.sh after curfew_net_harness.sh, which owns the +# stubs, the subject and the PASS/FAIL counters this file adds to. +set -euo pipefail + +# --- iptw ------------------------------------------------------------------- + +_reset_ipt +iptw iptables -L "${CURFEW_NET_IPT_CHAIN}" >/dev/null 2>&1 || true +if _calls | grep -q "^iptables -L"; then + _t_pass "iptw dispatches to the named binary" +else + _t_fail "iptw did not call the named binary" +fi + +# --- refresh_uid_cache ------------------------------------------------------ + +printf 'com.foo\ncom.bar\n' >"${STATE_DIR}/night_whitelist.txt" +printf 'com.foo uid:10123\ncom.bar uid:10456\n' >"${PM_FIXTURE}" +rm -f "${CURFEW_NET_UID_CACHE}" +refresh_uid_cache +_t_eq "10123 10456" "$(tr '\n' ' ' <"${CURFEW_NET_UID_CACHE}" | sed 's/ $//')" \ + "refresh_uid_cache writes the resolved uids" + +if [[ ! -f "${CURFEW_NET_UID_CACHE}.tmp" ]]; then + _t_pass "refresh_uid_cache moves its temp file into place" +else + _t_fail "refresh_uid_cache left a .tmp file behind" +fi + +# --- rebuild_net_from_cache ------------------------------------------------- + +_reset_ipt +rebuild_net_from_cache +if [[ -n "$(_rules_of iptables)" && -n "$(_rules_of ip6tables)" ]]; then + _t_pass "rebuild_net_from_cache builds both v4 and v6 chains" +else + _t_fail "rebuild_net_from_cache should build both address families" +fi + +# --- net_hold --------------------------------------------------------------- + +# Chain intact for the whole window: no rebuild. +_reset_ipt +_seed_jumps iptables 1 +# Read back after assigning: the subject is what consumes these, but each +# file is linted standalone, so an assignment with no reader here is SC2034. +CURFEW_NET_REASSERT_INTERVAL=1 +_t_eq "1" "${CURFEW_NET_REASSERT_INTERVAL}" "the reassert interval is set for net_hold" +_t_eq "0" "$(net_hold 2)" "net_hold does not rebuild while the chain survives" + +# Chain missing: the first check rebuilds it, and because the rebuild +# actually creates the chain the second check finds it present. One rebuild +# for a single flush is the self-healing behaviour, not a missed one. +_reset_ipt +_t_eq "1" "$(net_hold 2)" "net_hold rebuilds a vanished chain and then settles" + +# --- apply_net -------------------------------------------------------------- + +_reset_ipt +CURFEW_NET_ENABLED=0 +apply_net +_t_eq "" "$(_calls)" "apply_net does nothing while the net layer is disabled" +CURFEW_NET_ENABLED=1 +_t_eq "1" "${CURFEW_NET_ENABLED}" "the net layer is re-enabled for the remaining cases" + +_reset_ipt +NET_BUILT="" +apply_net +_t_eq "1" "${NET_BUILT}" "apply_net marks the chain as built" +if [[ -n "$(_rules_of iptables)" && -n "$(_rules_of ip6tables)" ]]; then + _t_pass "apply_net builds both address families" +else + _t_fail "apply_net should build both address families" +fi + +# A chain that vanished between ticks is logged, so a live test can tell +# "external flush + self-heal" from "the daemon died". +_reset_ipt +: >"${STATE_DIR}/log" +NET_BUILT=1 +apply_net +if grep -q "vanished since last tick" "${STATE_DIR}/log"; then + _t_pass "apply_net logs an externally flushed chain" +else + _t_fail "apply_net should log when the chain vanished between ticks" +fi + +# On a first build there is nothing to have vanished, so nothing is logged. +_reset_ipt +: >"${STATE_DIR}/log" +NET_BUILT="" +apply_net +if grep -q "vanished since last tick" "${STATE_DIR}/log"; then + _t_fail "apply_net logged a vanished chain on its first build" +else + _t_pass "apply_net stays quiet on the first build" +fi + +# --- teardown_net ----------------------------------------------------------- + +_reset_ipt +_seed_jumps iptables 2 +_seed_jumps ip6tables 1 +teardown_net + +# 2 jumps + 1 terminating failure on v4, 1 + 1 on v6 = 5. +_t_eq "5" "$(_calls | grep -c -- "-D OUTPUT -j FOCUS_CURFEW_NET")" \ + "teardown_net removes every OUTPUT jump on both families" +_t_eq "2" "$(_calls | grep -c -- "-X FOCUS_CURFEW_NET")" \ + "teardown_net deletes the chain on both families" + +if [[ -f "${RUN}/ipt/iptables/chain" ]]; then + _t_fail "teardown_net left the v4 chain in place" +else + _t_pass "teardown_net leaves no chain behind" +fi diff --git a/phone_focus_mode/lib/tests/curfew_net_harness.sh b/phone_focus_mode/lib/tests/curfew_net_harness.sh new file mode 100755 index 00000000..ec554600 --- /dev/null +++ b/phone_focus_mode/lib/tests/curfew_net_harness.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# lib/tests/curfew_net_harness.sh — staging and stubs for the curfew_net.sh +# tests. +# +# Sourced, not executed. Unlike the monitor tests, the stubs here go on PATH: +# curfew_net.sh calls iptables, ip6tables and pm as real binaries, so the +# process boundary is where the fake has to sit. Modelled on +# dns_iptables_harness.sh, which stubs the same two firewall binaries. +# +# The stub records every call in order, which is what makes the rule ordering +# assertable — the ACCEPT rules must precede the 10000-19999 REJECT or the +# whitelist is meaningless. +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" +} + +_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}/curfew_net.sh" "${RUN}/app/" + +# --- iptables/ip6tables stub ------------------------------------------------ +# +# Models a per-binary chain flag, a rule list and the OUTPUT jump count. Every +# call is logged so ordering can be asserted. Failure injection mirrors +# dns_iptables_harness.sh: a test touches fail_ to make that operation +# report failure, which is how the error returns become reachable. +cat >"${RUN}/bin/iptables" <<'STUB' +#!/usr/bin/env bash +bin="$(basename "$0")" +store="${IPT_STATE}/${bin}" +mkdir -p "$store" +rules="${store}/rules" +touch "$rules" + +# curfew_net always calls through iptw, which prepends `-w 2`. Drop it so the +# case below matches on the real operation. +[[ "$1" == "-w" ]] && shift 2 + +printf '%s %s\n' "$bin" "$*" >>"${IPT_STATE}/calls.log" + +_fails() { [[ -f "${IPT_STATE}/fail_$1" ]]; } + +_jumps_file="${store}/jumps" +[[ -f "$_jumps_file" ]] || echo 0 >"$_jumps_file" + +case "$1" in +-L) [[ -f "${store}/chain" ]] && exit 0 || exit 1 ;; +-N) + _fails N && exit 1 + touch "${store}/chain" + exit 0 + ;; +-F) + _fails F && exit 1 + : >"$rules" + exit 0 + ;; +-X) + rm -f "${store}/chain" + exit 0 + ;; +-A) + shift + printf -- '%s\n' "$*" >>"$rules" + exit 0 + ;; +-I) + _fails I && exit 1 + read -r n <"$_jumps_file" + echo $((n + 1)) >"$_jumps_file" + exit 0 + ;; +-D) + # Succeeds once per existing jump so the de-dupe loop terminates. + read -r n <"$_jumps_file" + if ((n > 0)); then + echo $((n - 1)) >"$_jumps_file" + exit 0 + fi + exit 1 + ;; +esac +exit 0 +STUB +chmod +x "${RUN}/bin/iptables" +cp "${RUN}/bin/iptables" "${RUN}/bin/ip6tables" + +# `pm list packages -U` output, seeded per test via PM_FIXTURE. +cat >"${RUN}/bin/pm" <<'STUB' +#!/usr/bin/env bash +[[ -f "${PM_FIXTURE}" ]] && cat "${PM_FIXTURE}" +exit 0 +STUB +chmod +x "${RUN}/bin/pm" + +export IPT_STATE="${RUN}/ipt" +export PM_FIXTURE="${RUN}/state/pm.txt" +export PATH="${RUN}/bin:${PATH}" + +# --- ambient globals the library reads from config.sh / the enforcer -------- + +STATE_DIR="${RUN}/state" +CURFEW_NET_IPT_CHAIN="FOCUS_CURFEW_NET" +CURFEW_NET_UID_CACHE="${RUN}/state/curfew_uids.cache" +CURFEW_NET_REASSERT_INTERVAL=1 +CURFEW_NET_ENABLED=1 +NET_BUILT="" + +log() { printf '%s\n' "$*" >>"${RUN}/state/log"; } + +# shellcheck source=../../curfew_net.sh +. "${RUN}/app/curfew_net.sh" + +_reset_ipt() { + rm -rf "${RUN}/ipt" + mkdir -p "${RUN}/ipt" +} + +# Make the next run report the given iptables operation as failing. +_fail_op() { + touch "${RUN}/ipt/fail_$1" +} + +_clear_fail() { + rm -f "${RUN}/ipt/fail_$1" +} + +# Pre-seed the chain as existing with N stale OUTPUT jumps, the lock-race +# state ensure_net_chain's de-dupe loop exists to clean up. +_seed_jumps() { + local bin="$1" count="$2" + mkdir -p "${RUN}/ipt/${bin}" + touch "${RUN}/ipt/${bin}/chain" + echo "$count" >"${RUN}/ipt/${bin}/jumps" +} + +# Rules recorded for a binary, one per line. +_rules_of() { + cat "${RUN}/ipt/$1/rules" 2>/dev/null || printf '' +} + +_calls() { + cat "${RUN}/ipt/calls.log" 2>/dev/null || printf '' +} diff --git a/phone_focus_mode/lib/tests/test_curfew_net.sh b/phone_focus_mode/lib/tests/test_curfew_net.sh new file mode 100755 index 00000000..a3fb4578 --- /dev/null +++ b/phone_focus_mode/lib/tests/test_curfew_net.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Unit tests for curfew_net.sh — the per-UID network allow-list of the night +# curfew — against the stubs in curfew_net_harness.sh. No device needed. +# +# What matters here is not that rules are added but that they are added in an +# order that means something: every ACCEPT must precede the 10000-19999 +# REJECT, or the whitelist lets nothing through. +# +# This is the only entry point; the cases live in two sourced files purely to +# stay under the 250-line cap. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=curfew_net_harness.sh +. "${SCRIPT_DIR}/curfew_net_harness.sh" +# shellcheck source=curfew_net_cases_chain.sh +. "${SCRIPT_DIR}/curfew_net_cases_chain.sh" +# shellcheck source=curfew_net_cases_lifecycle.sh +. "${SCRIPT_DIR}/curfew_net_cases_lifecycle.sh" + +printf '\nResults: %d passed, %d failed\n' "${PASS}" "${FAIL}" +[[ "${FAIL}" -eq 0 ]]