mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +01:00
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:
parent
b500b5daba
commit
ef892eb162
@ -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"
|
||||
}
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
123
phone_focus_mode/lib/tests/test_tether_enforcer.sh
Executable file
123
phone_focus_mode/lib/tests/test_tether_enforcer.sh
Executable 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 ]
|
||||
@ -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 &
|
||||
|
||||
|
||||
267
phone_focus_mode/tether_enforcer.sh
Executable file
267
phone_focus_mode/tether_enforcer.sh
Executable 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 "$@"
|
||||
Loading…
Reference in New Issue
Block a user