diff --git a/docs/superpowers/contracts/tether-enforcer-hotspot-bypass-2026-07-13.json b/docs/superpowers/contracts/tether-enforcer-hotspot-bypass-2026-07-13.json new file mode 100644 index 00000000..fadc7647 --- /dev/null +++ b/docs/superpowers/contracts/tether-enforcer-hotspot-bypass-2026-07-13.json @@ -0,0 +1,18 @@ +{ + "title": "Block hotspot/tethering to close the phone-focus second-phone bypass", + "objective": "Add a tether_enforcer to phone_focus_mode that stops a tethered second phone from bypassing focus mode. Nothing filtered forwarded/NAT'd traffic before; the enforcer cuts it off while focus mode is ON at home. Success = a second phone on bl-9000's hotspot cannot reach the internet while at home, and the phone's own connectivity is unaffected.", + "acceptance_criteria": [ + "tether_enforcer.sh self-gates on focus mode (MODE_FILE == focus) or a force-test file, and no-ops away from home.", + "While active it disables tether offload, pins a blanket REJECT on the FORWARD chain (iptables + ip6tables) above Android's tetherctrl_FORWARD, and best-effort stops the softAP.", + "The FORWARD chain is rebuilt only when tampered (chain_intact gate) so it does not fork iptables every tick.", + "On transition away from home / SIGTERM it restores the offload snapshot and tears down the FORWARD chain.", + "Wired into config.sh, focus_ctl.sh (tether-* commands), magisk_service.sh autostart, and deploy.sh push/cp/restart." + ], + "out_of_scope": [ + "Filtering the tethered device's DNS/hosts (full cutoff is used instead).", + "A companion-app (focus_status_app) button.", + "Persistent pm disable of any package (BL9000 MTK bootloop risk).", + "Fixing the pre-existing stale lib/tests/test_dns_enforcer.sh / test_magisk_service.sh mismatch." + ], + "verifier": "bash phone_focus_mode/lib/tests/test_tether_enforcer.sh (17 assertions) + pre-commit + on-device: focus_ctl.sh tether-status / iptables -S FORWARD on BL9000" +} diff --git a/docs/superpowers/evidence/tether-enforcer-hotspot-bypass-2026-07-13.json b/docs/superpowers/evidence/tether-enforcer-hotspot-bypass-2026-07-13.json new file mode 100644 index 00000000..4f5cd9dd --- /dev/null +++ b/docs/superpowers/evidence/tether-enforcer-hotspot-bypass-2026-07-13.json @@ -0,0 +1,34 @@ +{ + "intent": "Close the focus-mode bypass where enabling bl-9000's WiFi hotspot lets a second phone browse freely on the phone's mobile data, since no existing layer filters forwarded/tethered traffic.", + "scope": [ + "phone_focus_mode/tether_enforcer.sh (new always-on enforcer)", + "phone_focus_mode/config.sh, focus_ctl.sh, magisk_service.sh, deploy.sh, README.md (wiring)", + "Non-goal: filtering the tethered device's DNS (full cutoff instead); no companion-app button; no persistent package-disable (BL9000 MTK bootloop risk)" + ], + "changes": [ + "tether_enforcer.sh: self-gates on focus mode (MODE_FILE == focus) or a force-test file; converges three levers each tick: disable Android tether offload (settings global tether_offload_disabled=1, snapshot/restore), blanket REJECT of the FORWARD chain on iptables+ip6tables (chain FOCUS_TETHER_BLOCK, rebuilt only when tampered via chain_intact gate to avoid netd fork-storm), and best-effort 'cmd wifi stop-softap' (Android 11+). Reverts offload + tears down FORWARD on transition away from home / on SIGTERM.", + "config.sh: TETHER_ENFORCER_ENABLED/CHECK_INTERVAL/LOG/STATE/OFFLOAD_KEY/OFFLOAD_SNAP/IPT_CHAIN/STOP_SOFTAP_ENABLED/OVERRIDE_FILE/FORCE_FILE.", + "focus_ctl.sh: tether-status/start/stop/log/test-on/test-off commands + tether_enforcer_pid helper + case/usage entries.", + "magisk_service.sh: chmod + setsid autostart at boot. deploy.sh: push/cp/chmod/log-touch/kill/restart wiring." + ], + "verification": [ + { + "command": "local run harness with stubbed settings/iptables/ip6tables/cmd/getprop, driving the daemon through away/force-on/revert/focus/override", + "result": "pass", + "evidence": "18/18 assertions pass: gate correct in all 5 scenarios; offload snapshot(null)->set(1)->restore(delete); FORWARD jump pinned+torn down on v4+v6; exactly 1 REJECT rule (no rebuild loop); stop-softap invoked." + }, + { + "command": "on-device (BL9000, Android 13, USB adb+Magisk su): install + focus_ctl.sh tether-start + tether-status + iptables -S FORWARD", + "result": "pass", + "evidence": "Enforcer RUNNING; block ACTIVE at home. 'iptables -S FORWARD' shows '-A FORWARD -j FOCUS_TETHER_BLOCK' ABOVE '-A FORWARD -j tetherctrl_FORWARD' (our REJECT precedes Android's tether-accept). tether_offload_disabled=1. FOCUS_TETHER_BLOCK REJECT present on v4+v6. 'cmd wifi stop-softap' -> 'Soft AP stopped successfully'." + } + ], + "risks": [ + "Hardware/BPF tether offload could bypass the FORWARD rule on some ROMs; mitigated by disabling offload (key confirmed present on this ROM) and by actively stopping the softAP (confirmed working). Definitive second-phone end-to-end check is the on-device validation step.", + "Blocks legitimate at-home tethering (e.g. a work laptop) by design (user chose 'block whenever at home'); escape hatch is focus_ctl.sh tether-stop over ADB or a tether_override file." + ], + "rollback": [ + "focus_ctl.sh tether-stop tears down the FORWARD chain and restores offload; set TETHER_ENFORCER_ENABLED=0 in config.sh and redeploy to disable permanently.", + "After rollback validate: iptables -S FORWARD shows no FOCUS_TETHER_BLOCK jump; a tethered device reaches the internet again." + ] +} diff --git a/phone_focus_mode/README.md b/phone_focus_mode/README.md index ebd7d772..d1f86127 100644 --- a/phone_focus_mode/README.md +++ b/phone_focus_mode/README.md @@ -227,6 +227,79 @@ focus_ctl.sh curfew-test-off # blocked apps come BACK (the reconcile path) The clock parser fails **open** (treated as daytime) on a malformed time, so a broken `date` can never trap you behind the strict list. +## Hotspot / tethering block (closes the "second phone" bypass) + +Every other network layer only filters **this** phone's own traffic: +`/system/etc/hosts` is consulted by the phone's system resolver, and both +`dns_enforcer` and the curfew net layer only touch the **OUTPUT** chain. When +the phone shares its mobile data as a WiFi hotspot, a tethered second phone's +packets are **FORWARDed + NAT'd** through this phone on a path none of that +covers — so the second phone browses freely and defeats focus mode. + +`tether_enforcer.sh` closes that hole. It is always-on but **only acts while +focus mode is ON** (i.e. you are at home, `current_mode.txt == focus`), and +converges three levers every `TETHER_CHECK_INTERVAL` seconds: + +1. **Disable tether offload** — sets `settings global tether_offload_disabled 1` + so forwarded traffic is actually seen by netfilter instead of being shunted + around it by the hardware/BPF fast path. Snapshotted on entry, restored on + exit. +2. **FORWARD blanket REJECT** — an `iptables`/`ip6tables` chain + (`FOCUS_TETHER_BLOCK`) pinned at position 1 of `FORWARD`, rejecting all + forwarded packets. This is the version-independent catch-all and covers + **WiFi, USB and Bluetooth** tethering. The phone's own traffic uses + OUTPUT/INPUT, never FORWARD, so normal connectivity is untouched. Rebuilt + only when tampered (chain-intact gate), so it does not fork an `iptables -L` + every second (that pegged netd and overheated the phone during curfew-net + tuning). +3. **Stop the softAP** (best-effort, WiFi only, Android 11+) — `cmd wifi + stop-softap` each tick so the hotspot toggle visibly flips back off. + +On the transition away from home it restores the offload snapshot and tears the +FORWARD chain down, leaving tethering usable. + +### Configuration (`config.sh`) + +```sh +TETHER_ENFORCER_ENABLED=1 # master switch +TETHER_CHECK_INTERVAL=5 # re-assert cadence (seconds) +TETHER_STOP_SOFTAP_ENABLED=1 # also actively kill a running softAP +``` + +### Control + +```bash +focus_ctl.sh tether-status # enforcer state, offload flag, FORWARD chain +focus_ctl.sh tether-test-on # FORCE the block now (daytime validation) +focus_ctl.sh tether-test-off # clear the force +focus_ctl.sh tether-stop # escape hatch: stop it, restore tethering +focus_ctl.sh tether-start # (re)start the enforcer +focus_ctl.sh tether-log # enforcer log +``` + +The escape hatch is `tether-stop` over ADB (or a fresh `deploy.sh`), consistent +with `dns_enforcer`/`launcher_enforcer` — there is no companion-app button. +`tether_override` (created manually) suspends the block without stopping the +daemon. + +### Validating before you trust it + +Because Android's tether offload can silently skip the FORWARD rule on some +ROMs, validate on-device with a **real second phone** — do not trust rule +counts: + +```bash +focus_ctl.sh tether-test-on # hotspot on + second phone browsing → it loses + # internet within one interval; this phone stays + # online. Watch: focus_ctl.sh tether-log +focus_ctl.sh tether-test-off # forwarding restored, second phone browses again +``` + +If the second phone keeps browsing with the block applied, offload is being +bypassed: confirm the exact global key with +`adb shell su -c 'settings list global | grep -i offload'` and update +`TETHER_OFFLOAD_KEY` in `config.sh`. + ## Updating After editing `config.sh` (e.g. changing whitelist): diff --git a/phone_focus_mode/config.sh b/phone_focus_mode/config.sh index 686867d1..c8111bb1 100755 --- a/phone_focus_mode/config.sh +++ b/phone_focus_mode/config.sh @@ -111,6 +111,42 @@ export CURFEW_NET_UID_CACHE="$STATE_DIR/curfew_net_uids.txt" # the day. `curfew-test-off` removes it. export CURFEW_FORCE_FILE="$STATE_DIR/curfew_force_on" +# ============================================================ +# HOTSPOT / TETHERING BLOCK (closes the "second phone" bypass) +# ============================================================ +# The hosts file, dns_enforcer and the curfew net layer all only touch this +# phone's OWN traffic (system resolver + OUTPUT chain). A tethered second +# device's packets are FORWARDed + NAT'd through us on an unfiltered path, so +# it browses freely on our mobile data. tether_enforcer.sh closes that hole. +# Always-on like dns_enforcer, but only ACTS while focus mode is ON (i.e. you +# are at home: $MODE_FILE == "focus") OR the force-test file is present. Covers +# WiFi, USB and Bluetooth tethering, since all of it traverses FORWARD. +export TETHER_ENFORCER_ENABLED=1 +export TETHER_CHECK_INTERVAL=5 +export TETHER_LOG="$STATE_DIR/tether_enforcer.log" +export TETHER_ENFORCER_STATE="$STATE_DIR/tether_applied" +# Lever 1: disable Android's hardware/BPF tether offload so forwarded traffic +# is actually seen by netfilter (otherwise the FORWARD rule can be silently +# skipped). AOSP global key Settings.Global.TETHER_OFFLOAD_DISABLED; 1=disabled. +# Snapshotted on entry and restored on exit so we never clobber a user value. +export TETHER_OFFLOAD_KEY="tether_offload_disabled" +export TETHER_OFFLOAD_SNAP="$STATE_DIR/tether_offload.snap" +# Lever 2: blanket REJECT of the FORWARD chain (both iptables + ip6tables). +# Re-pinned only when tampered (chain_intact gate, like dns_enforcer) so we do +# not fork an `iptables -L` every second — that pegged netd + overheated the +# phone during curfew-net tuning (see CURFEW_NET_REASSERT_INTERVAL note above). +export TETHER_IPT_CHAIN="FOCUS_TETHER_BLOCK" +# Lever 3 (best-effort, WiFi only): actively stop a running softAP each tick so +# the hotspot toggle visibly flips off. Version-gated (cmd wifi stop-softap is +# Android 11+). The FORWARD block remains the version-independent catch-all. +export TETHER_STOP_SOFTAP_ENABLED=1 +# Escape hatches (mirrors the curfew override/force pair): +# tether_override -> suspend the block without stopping the daemon. +# tether_force_on -> force the block ACTIVE regardless of location (daytime +# on-device validation via `focus_ctl.sh tether-test-on`). +export TETHER_OVERRIDE_FILE="$STATE_DIR/tether_override" +export TETHER_FORCE_FILE="$STATE_DIR/tether_force_on" + # --- Boot-time autostart safety gate --- # Critical safety default: do NOT auto-start focus daemons at boot unless # explicitly enabled. This avoids device instability during early boot on diff --git a/phone_focus_mode/deploy.sh b/phone_focus_mode/deploy.sh index 4bbe546f..6d9bc1d4 100755 --- a/phone_focus_mode/deploy.sh +++ b/phone_focus_mode/deploy.sh @@ -374,6 +374,7 @@ do_deploy() { adb_cmd push "$SCRIPT_DIR/dns_enforcer.sh" "/data/local/tmp/focus_stage/dns_enforcer.sh" adb_cmd push "$SCRIPT_DIR/launcher_enforcer.sh" "/data/local/tmp/focus_stage/launcher_enforcer.sh" adb_cmd push "$SCRIPT_DIR/curfew_enforcer.sh" "/data/local/tmp/focus_stage/curfew_enforcer.sh" + adb_cmd push "$SCRIPT_DIR/tether_enforcer.sh" "/data/local/tmp/focus_stage/tether_enforcer.sh" adb_cmd push "$SCRIPT_DIR/workout_detector.sh" "/data/local/tmp/focus_stage/workout_detector.sh" adb_cmd push "$SCRIPT_DIR/magisk_service.sh" "/data/local/tmp/focus_stage/99-focus-mode.sh" @@ -486,6 +487,7 @@ do_deploy() { adb_root "cp /data/local/tmp/focus_stage/dns_enforcer.sh $REMOTE_DIR/dns_enforcer.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" adb_root "cp /data/local/tmp/focus_stage/workout_detector.sh $REMOTE_DIR/workout_detector.sh" if adb_cmd shell "test -f /data/local/tmp/focus_stage/sqlite3" 2>/dev/null; then adb_root "cp /data/local/tmp/focus_stage/sqlite3 $REMOTE_DIR/sqlite3" @@ -572,13 +574,13 @@ do_deploy() { done; true" echo "[5/7] Setting permissions..." - adb_root "chmod 755 $REMOTE_DIR/config.sh $REMOTE_DIR/focus_daemon.sh $REMOTE_DIR/focus_ctl.sh $REMOTE_DIR/hosts_enforcer.sh $REMOTE_DIR/dns_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh $REMOTE_DIR/curfew_enforcer.sh $REMOTE_DIR/workout_detector.sh" || true + adb_root "chmod 755 $REMOTE_DIR/config.sh $REMOTE_DIR/focus_daemon.sh $REMOTE_DIR/focus_ctl.sh $REMOTE_DIR/hosts_enforcer.sh $REMOTE_DIR/dns_enforcer.sh $REMOTE_DIR/launcher_enforcer.sh $REMOTE_DIR/curfew_enforcer.sh $REMOTE_DIR/tether_enforcer.sh $REMOTE_DIR/workout_detector.sh" || true if grep -q '^export FOCUS_BOOT_AUTOSTART=1' "$SCRIPT_DIR/config.sh"; then adb_root "chmod 755 /data/adb/service.d/99-focus-mode.sh" fi - adb_root "touch $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log $REMOTE_DIR/workout_detector.log" + adb_root "touch $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log $REMOTE_DIR/tether_enforcer.log $REMOTE_DIR/workout_detector.log" # State files need 666 so the daemons can write regardless of SELinux context drift - adb_root "chmod 666 $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log $REMOTE_DIR/workout_detector.log" || true + adb_root "chmod 666 $REMOTE_DIR/disabled_by_focus.txt $REMOTE_DIR/focus_mode.log $REMOTE_DIR/hosts_enforcer.log $REMOTE_DIR/dns_enforcer.log $REMOTE_DIR/launcher_enforcer.log $REMOTE_DIR/tether_enforcer.log $REMOTE_DIR/workout_detector.log" || true echo "[6/7] Starting daemons..." # Stop existing daemons, then start fresh @@ -587,11 +589,13 @@ do_deploy() { adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/dns_enforcer.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/launcher_enforcer.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/curfew_enforcer.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/tether_enforcer.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/workout_detector.sh' 2>/dev/null); do kill \"\$p\" 2>/dev/null || true; done" adb_root "kill \$(cat $REMOTE_DIR/daemon.pid 2>/dev/null) 2>/dev/null; true" adb_root "kill \$(cat $REMOTE_DIR/hosts_enforcer.pid 2>/dev/null) 2>/dev/null; true" adb_root "kill \$(cat $REMOTE_DIR/dns_enforcer.pid 2>/dev/null) 2>/dev/null; true" adb_root "kill \$(cat $REMOTE_DIR/launcher_enforcer.pid 2>/dev/null) 2>/dev/null; true" + adb_root "kill \$(cat $REMOTE_DIR/tether_enforcer.pid 2>/dev/null) 2>/dev/null; true" adb_root "kill \$(cat $REMOTE_DIR/workout_detector.pid 2>/dev/null) 2>/dev/null; true" sleep 1 adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/focus_daemon.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" @@ -599,9 +603,10 @@ do_deploy() { adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/dns_enforcer.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/launcher_enforcer.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/curfew_enforcer.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" + adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/tether_enforcer.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" adb_root "for p in \$(pgrep -f '/data/local/tmp/focus_mode/workout_detector.sh' 2>/dev/null); do kill -9 \"\$p\" 2>/dev/null || true; done" sleep 1 - adb_root "rm -f $REMOTE_DIR/daemon.pid $REMOTE_DIR/hosts_enforcer.pid $REMOTE_DIR/dns_enforcer.pid $REMOTE_DIR/launcher_enforcer.pid $REMOTE_DIR/workout_detector.pid" + adb_root "rm -f $REMOTE_DIR/daemon.pid $REMOTE_DIR/hosts_enforcer.pid $REMOTE_DIR/dns_enforcer.pid $REMOTE_DIR/launcher_enforcer.pid $REMOTE_DIR/tether_enforcer.pid $REMOTE_DIR/workout_detector.pid" # Start hosts enforcer first so hosts are locked before user can react. # Use --mount-master so bind mounts propagate to the global namespace # (where app processes live). Without this, only our isolated `su` session @@ -628,6 +633,10 @@ do_deploy() { # Start night-curfew enforcer (grayscale + DND + optional net allow-list). # Always on; self-gates on the clock + focus mode, no-op during the day. adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/curfew_enforcer.sh /dev/null 2>/dev/null &' + # Start hotspot/tethering enforcer (blocks forwarded/tethered traffic so a + # second phone on our hotspot can't bypass focus mode). Always on; self-gates + # on focus mode, no-op while away from home. + adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/tether_enforcer.sh /dev/null 2>/dev/null &' adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/focus_daemon.sh /dev/null 2>/dev/null &' # Wait for hosts_enforcer to apply the bind mount and restart netd. diff --git a/phone_focus_mode/focus_ctl.sh b/phone_focus_mode/focus_ctl.sh index 8520d007..07504041 100755 --- a/phone_focus_mode/focus_ctl.sh +++ b/phone_focus_mode/focus_ctl.sh @@ -89,6 +89,12 @@ usage() { echo " curfew-demo-off - Stop the demo" echo " curfew-off - Escape hatch: suspend curfew now (2am opt-out)" echo " curfew-on - Re-arm curfew (clear the override)" + echo " tether-status - Show hotspot/tethering-block state" + echo " tether-start - Start the tether enforcer (FORWARD block + offload off)" + echo " tether-stop - Stop it and restore tethering (teardown FORWARD chain)" + echo " tether-log - Show tether enforcer log" + echo " tether-test-on - Force the tether block ACTIVE now (daytime validation)" + echo " tether-test-off - Clear the tether force" echo " notif-status - Show companion status-notification details" echo "" } @@ -917,6 +923,102 @@ cmd_curfew_on() { echo "Curfew re-armed (override cleared)." } +# ============================================================ +# Hotspot / tethering block control (see tether_enforcer.sh) +# ============================================================ +TETHER_PIDFILE="$STATE_DIR/tether_enforcer.pid" + +tether_enforcer_pid() { + if [ -f "$TETHER_PIDFILE" ]; then + local pid + pid="$(cat "$TETHER_PIDFILE")" + if kill -0 "$pid" 2>/dev/null; then + echo "$pid" + fi + fi +} + +cmd_tether_status() { + local pid + pid="$(tether_enforcer_pid)" + echo "=== Hotspot / Tethering Block Status ===" + echo "Enabled: ${TETHER_ENFORCER_ENABLED}" + if [ -n "$pid" ]; then + echo "Enforcer: RUNNING (PID $pid)" + else + echo "Enforcer: STOPPED" + fi + if [ -f "$MODE_FILE" ]; then + echo "Focus mode: $(cat "$MODE_FILE" 2>/dev/null)" + fi + [ -e "$TETHER_FORCE_FILE" ] && echo "Forced ON: YES (test active)" + [ -e "$TETHER_OVERRIDE_FILE" ] && echo "Override: YES (block SUSPENDED)" + [ -e "$TETHER_ENFORCER_STATE" ] && echo "Applied now: YES (offload off + FORWARD reject)" \ + || echo "Applied now: no" + local off + off="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)" + echo "tether_offload_disabled: ${off:-}" + if iptables -C FORWARD -j "$TETHER_IPT_CHAIN" >/dev/null 2>&1; then + echo "iptables $TETHER_IPT_CHAIN: pinned to FORWARD ($(iptables -S "$TETHER_IPT_CHAIN" 2>/dev/null | grep -c '^-A') rule)" + else + echo "iptables $TETHER_IPT_CHAIN: absent (block not applied)" + fi +} + +cmd_tether_start() { + local pid + pid="$(tether_enforcer_pid)" + if [ -n "$pid" ]; then + echo "Tether enforcer already running (PID $pid)" + return + fi + setsid sh "$SCRIPT_DIR/tether_enforcer.sh" /dev/null 2>&1 & + sleep 2 + pid="$(tether_enforcer_pid)" + if [ -n "$pid" ]; then + echo "Tether enforcer started (PID $pid)" + else + echo "ERROR: tether enforcer failed to start. Check log: $TETHER_LOG" + fi +} + +cmd_tether_stop() { + local pid + pid="$(tether_enforcer_pid)" + if [ -z "$pid" ]; then + echo "Tether enforcer not running" + rm -f "$TETHER_PIDFILE" + else + kill -TERM "$pid" + echo "Tether enforcer stopped (sent SIGTERM to PID $pid)" + fi + # Explicit teardown so maintenance can tether even if the daemon was already + # dead (its own TERM handler reverts too; this is the belt-and-suspenders). + iptables -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null || true + iptables -F "$TETHER_IPT_CHAIN" 2>/dev/null || true + iptables -X "$TETHER_IPT_CHAIN" 2>/dev/null || true + ip6tables -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null || true + ip6tables -F "$TETHER_IPT_CHAIN" 2>/dev/null || true + ip6tables -X "$TETHER_IPT_CHAIN" 2>/dev/null || true + echo "FORWARD chain $TETHER_IPT_CHAIN removed (tethering restored)" +} + +cmd_tether_log() { tail -n "${1:-50}" "$TETHER_LOG" 2>/dev/null || echo "No tether log yet."; } + +# Test hook: force the block ACTIVE regardless of location so the full stack can +# be validated during the day with a real second phone on the hotspot. +cmd_tether_test_on() { + touch "$TETHER_FORCE_FILE"; chmod 666 "$TETHER_FORCE_FILE" 2>/dev/null || true + rm -f "$TETHER_OVERRIDE_FILE" + echo "Tether block FORCED ON. Enforcer engages within ${TETHER_CHECK_INTERVAL}s." + echo "Validate: second phone on the hotspot loses internet; this phone stays online." +} + +cmd_tether_test_off() { + rm -f "$TETHER_FORCE_FILE" + echo "Tether force cleared. Back to focus-mode-gated behaviour." +} + case "$1" in start) cmd_start ;; stop) cmd_stop ;; @@ -956,5 +1058,11 @@ case "$1" in curfew-demo-off) cmd_curfew_demo_off ;; curfew-off) cmd_curfew_off ;; curfew-on) cmd_curfew_on ;; + tether-status) cmd_tether_status ;; + tether-start) cmd_tether_start ;; + tether-stop) cmd_tether_stop ;; + tether-log) cmd_tether_log "${2:-50}" ;; + tether-test-on) cmd_tether_test_on ;; + tether-test-off) cmd_tether_test_off ;; *) usage ;; esac diff --git a/phone_focus_mode/lib/tests/test_tether_enforcer.sh b/phone_focus_mode/lib/tests/test_tether_enforcer.sh new file mode 100755 index 00000000..fb355801 --- /dev/null +++ b/phone_focus_mode/lib/tests/test_tether_enforcer.sh @@ -0,0 +1,123 @@ +#!/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" < "$RUN/bin/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" < "\$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 ""; } +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)" = "" ] && 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)" = "" ] && 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 ] diff --git a/phone_focus_mode/magisk_service.sh b/phone_focus_mode/magisk_service.sh index 322e2905..939d67ad 100755 --- a/phone_focus_mode/magisk_service.sh +++ b/phone_focus_mode/magisk_service.sh @@ -18,6 +18,7 @@ chmod +x "$SCRIPT_DIR/hosts_enforcer.sh" chmod +x "$SCRIPT_DIR/dns_enforcer.sh" chmod +x "$SCRIPT_DIR/launcher_enforcer.sh" chmod +x "$SCRIPT_DIR/curfew_enforcer.sh" 2>/dev/null +chmod +x "$SCRIPT_DIR/tether_enforcer.sh" 2>/dev/null chmod +x "$SCRIPT_DIR/workout_detector.sh" 2>/dev/null chmod +x "$SCRIPT_DIR/sqlite3" 2>/dev/null @@ -47,6 +48,11 @@ setsid sh "$SCRIPT_DIR/launcher_enforcer.sh" /dev/null 2>&1 & # self-gates on the clock + focus mode and is a no-op during the day. setsid sh "$SCRIPT_DIR/curfew_enforcer.sh" /dev/null 2>&1 & +# Start hotspot/tethering enforcer - blocks forwarded (tethered) traffic so a +# second phone on our hotspot can't bypass focus mode. Always on; it self-gates +# on focus mode and is a no-op while away from home. +setsid sh "$SCRIPT_DIR/tether_enforcer.sh" /dev/null 2>&1 & + # Start focus daemon in a new session (detached from any controlling terminal) setsid sh "$SCRIPT_DIR/focus_daemon.sh" /dev/null 2>&1 & diff --git a/phone_focus_mode/tether_enforcer.sh b/phone_focus_mode/tether_enforcer.sh new file mode 100755 index 00000000..dd37261a --- /dev/null +++ b/phone_focus_mode/tether_enforcer.sh @@ -0,0 +1,267 @@ +#!/system/bin/sh +# shellcheck shell=ash +# ============================================================ +# Hotspot / tethering enforcer for rooted Android. +# +# Why this exists: +# Every other network layer here only filters THIS phone's own traffic: +# /system/etc/hosts is consulted by the phone's system resolver, and both +# dns_enforcer and the curfew net layer only touch the OUTPUT chain. When the +# phone shares its mobile data as a WiFi/USB/BT hotspot, the tethered device's +# packets are FORWARDed + NAT'd through us on a path none of that covers - so +# a second phone browses freely and defeats focus mode entirely. +# +# Strategy (belt-and-suspenders; all best-effort, converged every tick): +# 1. Disable tether offload (settings global) so forwarded traffic is not +# shunted around netfilter by the hardware/BPF fast path. +# 2. Blanket REJECT of the FORWARD chain (iptables + ip6tables). This is the +# version-independent catch-all and covers WiFi, USB and BT tethering. +# The phone's own traffic uses OUTPUT/INPUT, never FORWARD, so normal +# connectivity is untouched. +# 3. Best-effort actively stop a running softAP (WiFi only; Android 11+) so +# the hotspot toggle visibly flips back off. +# +# Acts ONLY while focus mode is ON ($MODE_FILE == "focus", i.e. at home) or the +# force-test file is present. On the transition away from home it restores the +# offload snapshot and tears the FORWARD chain down, leaving tethering usable. +# +# "Locked" = snap-back: re-toggling the hotspot on is reverted within one tick. +# ============================================================ + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=config.sh +. "$SCRIPT_DIR/config.sh" + +PIDFILE="$STATE_DIR/tether_enforcer.pid" + +mkdir -p "$STATE_DIR" +touch "$TETHER_LOG" +chmod 666 "$TETHER_LOG" 2>/dev/null || true + +log() { + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[$ts] $1" >> "$TETHER_LOG" +} + +rotate_log() { + local lines + lines="$(wc -l < "$TETHER_LOG" 2>/dev/null || echo 0)" + if [ "$lines" -gt 500 ]; then + local tmp="$TETHER_LOG.tmp" + tail -n 500 "$TETHER_LOG" > "$tmp" + mv "$tmp" "$TETHER_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 "tether_enforcer"; then + echo "tether_enforcer already running (PID $old_pid)" + exit 0 + fi + fi + rm -f "$PIDFILE" + fi + echo $$ > "$PIDFILE" +} + +# ---- Activation gate (mirrors curfew_enforcer.sh::should_act) ---- + +at_home() { + [ -f "$MODE_FILE" ] && [ "$(cat "$MODE_FILE" 2>/dev/null)" = "focus" ] +} + +should_act() { + [ "${TETHER_ENFORCER_ENABLED:-0}" = "1" ] || return 1 + [ -e "$TETHER_OVERRIDE_FILE" ] && return 1 + # Force hook bypasses the home gate so the full stack can be validated + # during the day from anywhere (`focus_ctl.sh tether-test-on`). + [ -e "$TETHER_FORCE_FILE" ] && return 0 + at_home +} + +# ---- Lever 1: tether offload ---- + +apply_offload_off() { + local cur + cur="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)" + if [ "$cur" != "1" ]; then + settings put global "$TETHER_OFFLOAD_KEY" 1 2>/dev/null \ + && log "tether offload was '$cur' - forced disabled (=1)" + fi +} + +snapshot_offload() { + local cur + cur="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)" + printf '%s\n' "${cur:-null}" > "$TETHER_OFFLOAD_SNAP" 2>/dev/null || true +} + +restore_offload() { + local snap + [ -f "$TETHER_OFFLOAD_SNAP" ] && snap="$(cat "$TETHER_OFFLOAD_SNAP" 2>/dev/null)" + case "$snap" in + ''|null) + # No pre-existing value: clear ours so the OS default returns. + settings delete global "$TETHER_OFFLOAD_KEY" 2>/dev/null || true + ;; + *) + settings put global "$TETHER_OFFLOAD_KEY" "$snap" 2>/dev/null || true + ;; + esac +} + +# ---- Lever 2: FORWARD chain blanket REJECT ---- + +# Run iptables/ip6tables with a 2s xtables lock-wait so our calls queue for the +# lock instead of silently failing the instant netd holds it (proven necessary +# on-device for the curfew net layer). $1 = binary. +iptw() { + local bin="$1" + shift + "$bin" -w 2 "$@" +} + +ensure_chain() { + local ipt="$1" + if ! iptw "$ipt" -L "$TETHER_IPT_CHAIN" >/dev/null 2>&1; then + iptw "$ipt" -N "$TETHER_IPT_CHAIN" 2>/dev/null || { + log "ERROR: could not create $ipt chain $TETHER_IPT_CHAIN" + return 1 + } + fi + # De-dupe: remove every existing FORWARD jump, then pin exactly one at #1 + # (netd inserts its own tethering rules into FORWARD, so we must be first). + while iptw "$ipt" -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null; do :; done + iptw "$ipt" -I FORWARD 1 -j "$TETHER_IPT_CHAIN" 2>/dev/null || { + log "ERROR: could not insert FORWARD -> $TETHER_IPT_CHAIN for $ipt" + return 1 + } +} + +fill_chain() { + local ipt="$1" reject="$2" + iptw "$ipt" -F "$TETHER_IPT_CHAIN" 2>/dev/null || return 1 + # Single rule: reject everything that would be forwarded through us. + iptw "$ipt" -A "$TETHER_IPT_CHAIN" -j REJECT --reject-with "$reject" 2>/dev/null || true +} + +# Only rebuild when actually tampered (chain missing, unlinked from FORWARD, or +# wrong size). Avoids forking a flush+refill every tick, which pegs netd. +chain_intact() { + local ipt="$1" actual + iptw "$ipt" -C FORWARD -j "$TETHER_IPT_CHAIN" >/dev/null 2>&1 || return 1 + actual="$(iptw "$ipt" -S "$TETHER_IPT_CHAIN" 2>/dev/null | grep -c '^-A')" + [ "$actual" = "1" ] +} + +apply_forward_block() { + if command -v iptables >/dev/null 2>&1; then + if chain_intact iptables; then :; \ + elif ensure_chain iptables && fill_chain iptables icmp-port-unreachable; then + log "iptables (v4) FORWARD block rebuilt (was missing/tampered)" + fi + fi + if command -v ip6tables >/dev/null 2>&1; then + if chain_intact ip6tables; then :; \ + elif ensure_chain ip6tables && fill_chain ip6tables icmp6-port-unreachable; then + log "ip6tables (v6) FORWARD block rebuilt (was missing/tampered)" + fi + fi +} + +teardown_forward_block() { + local ipt + for ipt in iptables ip6tables; do + command -v "$ipt" >/dev/null 2>&1 || continue + while iptw "$ipt" -D FORWARD -j "$TETHER_IPT_CHAIN" 2>/dev/null; do :; done + iptw "$ipt" -F "$TETHER_IPT_CHAIN" 2>/dev/null || true + iptw "$ipt" -X "$TETHER_IPT_CHAIN" 2>/dev/null || true + done +} + +# ---- Lever 3: actively stop the softAP (best-effort, WiFi only) ---- + +_android_major() { + local ver="${1%%.*}" + case "$ver" in + ''|*[!0-9]*) echo 0 ;; + *) echo "$ver" ;; + esac +} + +stop_softap() { + [ "${TETHER_STOP_SOFTAP_ENABLED:-0}" = "1" ] || return 0 + command -v cmd >/dev/null 2>&1 || return 0 + local major + major="$(_android_major "$(getprop ro.build.version.release 2>/dev/null)")" + # `cmd wifi stop-softap` landed in Android 11. Best-effort, idempotent: it + # no-ops (non-zero) when no softAP is running, which we intentionally ignore. + if [ "$major" -ge 11 ]; then + cmd wifi stop-softap >/dev/null 2>&1 || true + else + cmd wifi set-wifi-ap-enabled false >/dev/null 2>&1 || true + fi +} + +# ---- Apply / revert orchestration ---- + +enter_block() { + if [ ! -e "$TETHER_ENFORCER_STATE" ]; then + snapshot_offload + : > "$TETHER_ENFORCER_STATE" + log "Tether block ON (focus mode) - offload off + FORWARD reject${TETHER_STOP_SOFTAP_ENABLED:+ + stop-softap}" + fi + apply_offload_off + apply_forward_block + stop_softap +} + +exit_block() { + [ -e "$TETHER_ENFORCER_STATE" ] || return 0 + restore_offload + teardown_forward_block + rm -f "$TETHER_ENFORCER_STATE" + log "Tether block OFF - restored offload, tore down FORWARD chain" +} + +cleanup() { + # Clean stop leaves tethering usable again (the intuitive escape hatch: + # `focus_ctl.sh tether-stop`). + log "tether_enforcer shutting down - reverting" + exit_block + rm -f "$PIDFILE" + exit 0 +} + +trap cleanup INT TERM + +main() { + acquire_lock + log "tether_enforcer started (PID=$$, enabled=${TETHER_ENFORCER_ENABLED})" + local tick=0 act fwd + while true; do + if should_act; then act=1; enter_block; else act=0; exit_block; fi + # Heartbeat every ~6 ticks (~30s) so "alive but idle" is distinguishable + # from "dead" in the log. + tick=$((tick + 1)) + if [ "$((tick % 6))" -eq 0 ]; then + if iptw iptables -C FORWARD -j "$TETHER_IPT_CHAIN" >/dev/null 2>&1; then + fwd=up + else + fwd=down + fi + log "heartbeat tick=$tick act=$act forward=$fwd" + fi + rotate_log + sleep "$TETHER_CHECK_INTERVAL" + done +} + +main "$@"