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
This commit is contained in:
Krzysztof Rudnicki 2026-07-13 10:48:59 +02:00
parent b500b5daba
commit ef892eb162
9 changed files with 678 additions and 4 deletions

View File

@ -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"
}

View File

@ -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."
]
}

View File

@ -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):

View File

@ -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

View File

@ -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 >/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 >/dev/null 2>/dev/null &'
adb_cmd shell su --mount-master -c 'setsid sh /data/local/tmp/focus_mode/focus_daemon.sh </dev/null >/dev/null 2>/dev/null &'
# Wait for hosts_enforcer to apply the bind mount and restart netd.

View File

@ -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:-<unset>}"
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 >/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

View File

@ -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" <<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 ]

View File

@ -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 >/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 >/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 >/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 >/dev/null 2>&1 &

View File

@ -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 "$@"