testsAndMisc/phone_focus_mode/lib/tests/test_tether_enforcer.sh
Krzysztof kuhy Rudnicki ef892eb162 phone_focus_mode: block hotspot/tethering to close the second-phone bypass
The focus system only ever filtered this phone's own traffic: /system/etc/hosts
via the system resolver, dns_enforcer and the curfew net layer via OUTPUT. A
tethered second phone's packets are FORWARDed + NAT'd through us on a path none
of that covers, so a second phone on the hotspot browses freely on our mobile
data and defeats focus mode.

Add tether_enforcer.sh: an always-on daemon that acts while focus mode is ON
(at home) or a force-test file is present, converging three levers each tick:
  1. disable Android tether offload (settings global tether_offload_disabled=1,
     snapshot/restore) so forwarded traffic goes through netfilter;
  2. blanket REJECT of the FORWARD chain (iptables + ip6tables, FOCUS_TETHER_BLOCK
     pinned at position 1), rebuilt only when tampered via a chain_intact gate so
     it does not fork an `iptables -L` every second (that overheated the phone
     during curfew-net tuning). Covers WiFi, USB and BT tethering;
  3. best-effort `cmd wifi stop-softap` (Android 11+) so the hotspot visibly
     flips off.
Reverts offload + tears down FORWARD on transition away from home / on SIGTERM.

Wiring: config.sh flags, focus_ctl.sh (tether-status/start/stop/log/test-on/off),
magisk_service.sh boot autostart, deploy.sh push/cp/restart. README section and
a working subprocess regression test (lib/tests/test_tether_enforcer.sh, 17/17).

Verified on-device (BL9000, Android 13): our REJECT jump sits ABOVE Android's
tetherctrl_FORWARD chain, tether_offload_disabled=1 took effect, and
`cmd wifi stop-softap` reports "Soft AP stopped successfully".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6i3cdhKBoUrKxy4rmx6hH
2026-07-13 10:48:59 +02:00

124 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Regression test for tether_enforcer.sh.
#
# The enforcer ends in `main "$@"` (an infinite loop), so — like a real deploy —
# we run it as a subprocess with the Android tools it calls (settings, iptables,
# ip6tables, cmd, getprop) replaced by stubs on PATH, and drive it through the
# full gate + apply/revert state machine, asserting observable state each step.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC="$SCRIPT_DIR/../../tether_enforcer.sh"
RUN="$(mktemp -d)"
cleanup() {
if [ -n "${PID:-}" ]; then
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true # let its TERM handler finish before rm
fi
rm -rf "$RUN"
}
trap cleanup EXIT
mkdir -p "$RUN/bin" "$RUN/state" "$RUN/ipt" "$RUN/settings" "$RUN/calls" "$RUN/app"
STATE="$RUN/state"
# --- fake config.sh in the daemon's own dir (it sources "$SCRIPT_DIR/config.sh") ---
cp "$SRC" "$RUN/app/tether_enforcer.sh"
cat > "$RUN/app/config.sh" <<EOF
export STATE_DIR="$STATE"
export MODE_FILE="$STATE/current_mode.txt"
export TETHER_ENFORCER_ENABLED=1
export TETHER_CHECK_INTERVAL=1
export TETHER_LOG="$STATE/tether_enforcer.log"
export TETHER_ENFORCER_STATE="$STATE/tether_applied"
export TETHER_OFFLOAD_KEY="tether_offload_disabled"
export TETHER_OFFLOAD_SNAP="$STATE/tether_offload.snap"
export TETHER_IPT_CHAIN="FOCUS_TETHER_BLOCK"
export TETHER_STOP_SOFTAP_ENABLED=1
export TETHER_OVERRIDE_FILE="$STATE/tether_override"
export TETHER_FORCE_FILE="$STATE/tether_force_on"
EOF
# --- stubs ---
cat > "$RUN/bin/settings" <<EOF
#!/usr/bin/env bash
db="$RUN/settings/tether_offload_disabled"
echo "settings \$*" >> "$RUN/calls/settings.log"
case "\$1 \$2" in
"get global") [ -f "\$db" ] && cat "\$db" || echo "null" ;;
"put global") printf '%s' "\$4" > "\$db" ;;
"delete global") rm -f "\$db" ;;
esac
exit 0
EOF
# iptables/ip6tables: minimal chain-state model so chain_intact converges.
cat > "$RUN/bin/iptables" <<EOF
#!/usr/bin/env bash
bin="\$(basename "\$0")"; s="$RUN/ipt/\$bin"; mkdir -p "\$s"
[ "\$1" = "-w" ] && shift 2
case "\$1" in
-L) [ -f "\$s/exists" ] ;;
-N) touch "\$s/exists"; : > "\$s/rules" ;;
-C) [ -f "\$s/jump" ] ;;
-D) if [ -f "\$s/jump" ]; then rm -f "\$s/jump"; exit 0; else exit 1; fi ;;
-I) touch "\$s/jump" ;;
-A) echo "-A rule" >> "\$s/rules" ;;
-F) : > "\$s/rules" ;;
-S) cat "\$s/rules" 2>/dev/null ;;
-X) rm -f "\$s/exists" "\$s/rules" "\$s/jump" ;;
esac
exit \$?
EOF
cp "$RUN/bin/iptables" "$RUN/bin/ip6tables"
printf '#!/usr/bin/env bash\necho "cmd $*" >> "%s/calls/cmd.log"\nexit 0\n' "$RUN" > "$RUN/bin/cmd"
printf '#!/usr/bin/env bash\necho 13\n' > "$RUN/bin/getprop"
chmod +x "$RUN/bin/"*
export PATH="$RUN/bin:$PATH"
PASS=0; FAIL=0
ok() { PASS=$((PASS + 1)); printf ' OK: %s\n' "$1"; }
no() { FAIL=$((FAIL + 1)); printf ' FAIL: %s\n' "$1"; }
applied() { [ -f "$STATE/tether_applied" ]; }
offload() { cat "$RUN/settings/tether_offload_disabled" 2>/dev/null || echo "<unset>"; }
v4jump() { [ -f "$RUN/ipt/iptables/jump" ]; }
v6jump() { [ -f "$RUN/ipt/ip6tables/jump" ]; }
v4rules() { grep -c '^-A' "$RUN/ipt/iptables/rules" 2>/dev/null || echo 0; }
softap() { grep -c 'stop-softap' "$RUN/calls/cmd.log" 2>/dev/null || echo 0; }
sh "$RUN/app/tether_enforcer.sh" & PID=$!
sleep 2
# [A] Away from home -> block OFF
applied && no "A: applied while away" || ok "A: not applied while away"
[ "$(offload)" = "<unset>" ] && ok "A: offload untouched away" || no "A: offload changed away"
v4jump && no "A: v4 jump while away" || ok "A: no v4 jump while away"
# [B] force-on -> block ENGAGES (all three levers)
touch "$STATE/tether_force_on"; sleep 3
applied && ok "B: applied on force" || no "B: not applied on force"
[ "$(offload)" = "1" ] && ok "B: offload disabled" || no "B: offload=$(offload)"
v4jump && ok "B: v4 FORWARD jump pinned" || no "B: no v4 jump"
v6jump && ok "B: v6 FORWARD jump pinned" || no "B: no v6 jump"
[ "$(v4rules)" = "1" ] && ok "B: single REJECT rule (no rebuild loop)" || no "B: v4 rules=$(v4rules)"
[ "$(softap)" -ge 1 ] && ok "B: stop-softap invoked" || no "B: softap not stopped"
[ "$(cat "$STATE/tether_offload.snap" 2>/dev/null)" = "null" ] && ok "B: offload snapshot captured" || no "B: bad snapshot"
# [C] clear force -> block REVERTS
rm -f "$STATE/tether_force_on"; sleep 3
applied && no "C: still applied" || ok "C: reverted on force-clear"
[ "$(offload)" = "<unset>" ] && ok "C: offload restored" || no "C: offload=$(offload)"
v4jump && no "C: v4 jump remains" || ok "C: v4 chain torn down"
# [D] current_mode=focus -> ENGAGES via home gate
echo focus > "$STATE/current_mode.txt"; sleep 3
applied && ok "D: applied at home (focus)" || no "D: not applied at home"
v4jump && ok "D: v4 jump pinned at home" || no "D: no v4 jump at home"
# [E] override -> SUSPENDS even at home
touch "$STATE/tether_override"; sleep 3
applied && no "E: applied despite override" || ok "E: suspended by override"
v4jump && no "E: v4 jump despite override" || ok "E: torn down by override"
printf '\n%d passed, %d failed\n' "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ]