diff --git a/docs/explanations/2026-07-13-explanation-tether-hotspot-block.html b/docs/explanations/2026-07-13-explanation-tether-hotspot-block.html new file mode 100644 index 00000000..446205c2 --- /dev/null +++ b/docs/explanations/2026-07-13-explanation-tether-hotspot-block.html @@ -0,0 +1,453 @@ +
How a new tether_enforcer.sh stops a second phone on the hotspot from walking straight around focus mode — and why it lives in the FORWARD chain, not OUTPUT.
phone_focus_mode is a location-gated app blocker for a rooted Android phone (Magisk). When the phone is within ~150 m of home it enters focus mode: a daemon pm disable-users every app that isn't whitelisted, and several always-on "enforcer" daemons harden the phone further. The change lives entirely in this subsystem.
Three of those enforcers are about the network, and all three share a hidden assumption. To see it you need one idea from the Linux firewall (netfilter): every IP packet the kernel handles passes through one of a few chains, depending on where it's going.
Here is what each existing layer actually inspects, straight from the scripts:
+hosts_enforcer.sh locks /system/etc/hosts — but that file is only consulted by this phone's system resolver.dns_enforcer.sh blocks DoH/DoT by adding rules to the OUTPUT chain.curfew_enforcer.sh is a per-UID allow-list, also on OUTPUT.Every one of them filters traffic the phone sends on its own behalf. None of them touches FORWARD. That is the seam the bug slips through.
Turn the phone into a Wi-Fi hotspot and connect a second phone to it. Now the second phone's packets don't originate on your phone — they arrive over Wi-Fi, get NAT'd, and leave over mobile data. They are forwarded, so they travel the FORWARD chain, which nobody was guarding. The second phone browses YouTube freely on your data while your own phone sits there dutifully blocked.
The fix is a fourth enforcer, tether_enforcer.sh. While focus mode is on (i.e. you're home), it does three things every few seconds, and it's careful to be reversible the moment you leave:
REJECT at the top of FORWARD.FOCUS_TETHER_BLOCK is rule #1 → REJECTTwo subtleties make or break this, and both are addressed in the code:
+FORWARD (tetherctrl_FORWARD). Our reject only wins if it's evaluated first — so it's inserted at position 1, above Android's rules. Verified on-device: our jump sits on line 2 of FORWARD, Android's on line 6.tether_offload_disabled=1 forces the slow path so the rule bites.FORWARDPick who is sending, toggle focus mode, and step the packet down the chain. Notice that the phone's own traffic never enters FORWARD at all — which is exactly why a blanket reject here is safe.
The FOCUS_TETHER_BLOCK rule only exists while focus mode is on — that's the should_act gate. Turn focus off and the chain reverts to Android's defaults.
~690 lines across 9 files, but it groups into four concerns. The heart is the new tether_enforcer.sh; everything else wires it into the existing machinery.
Like the curfew enforcer, this daemon runs forever but only acts under specific conditions. The gate mirrors curfew_enforcer.sh::should_act, but keyed on focus mode instead of the clock:
should_act() {
+ [ "${TETHER_ENFORCER_ENABLED:-0}" = "1" ] || return 1
+ [ -e "$TETHER_OVERRIDE_FILE" ] && return 1 # escape hatch: suspend
+ [ -e "$TETHER_FORCE_FILE" ] && return 0 # daytime test hook
+ at_home # MODE_FILE == "focus"
+}
+ An override file lets you suspend it without killing the daemon (tethering a laptop at home); a force file lets you validate the whole stack in daylight without waiting to be home.
Offload is snapshotted before we touch it, so leaving home restores whatever value you had (mirrors the curfew enforcer's grayscale snapshot/restore):
+apply_offload_off() {
+ cur="$(settings get global "$TETHER_OFFLOAD_KEY" 2>/dev/null)"
+ [ "$cur" != "1" ] && settings put global "$TETHER_OFFLOAD_KEY" 1 ...
+}
+ The FORWARD block pins one jump at the top of FORWARD and fills a single-rule reject chain. Crucially, it only rebuilds when something has tampered with it — a lesson paid for in the curfew net layer, where flushing every tick pegged netd and overheated the phone:
chain_intact() { # cheap check: jump present + exactly 1 rule
+ iptw "$ipt" -C FORWARD -j "$TETHER_IPT_CHAIN" >/dev/null 2>&1 || return 1
+ actual="$(iptw "$ipt" -S "$TETHER_IPT_CHAIN" | grep -c '^-A')"
+ [ "$actual" = "1" ]
+}
+apply_forward_block() {
+ if chain_intact iptables; then :; # already good — do nothing, no fork
+ elif ensure_chain iptables && fill_chain iptables icmp-port-unreachable; then
+ log "iptables (v4) FORWARD block rebuilt (was missing/tampered)"
+ fi
+ # ... same for ip6tables
+}
+ Stopping the softAP is best-effort and version-gated — cmd wifi stop-softap only exists on Android 11+:
stop_softap() {
+ major="$(_android_major "$(getprop ro.build.version.release)")"
+ [ "$major" -ge 11 ] && cmd wifi stop-softap >/dev/null 2>&1 || true
+}
+
+ A state marker file means the "engage" work logs once, not every tick, and the "revert" only runs on a real transition. On SIGTERM the cleanup path reverts too, so tether-stop is a clean escape hatch:
enter_block() { # apply all three levers; snapshot offload on first entry
+ [ -e "$TETHER_ENFORCER_STATE" ] || snapshot_offload
+ apply_offload_off; apply_forward_block; stop_softap
+}
+exit_block() { # restore offload, tear down the chain — only if we had applied
+ [ -e "$TETHER_ENFORCER_STATE" ] || return 0
+ restore_offload; teardown_forward_block
+}
+main() { while true; do
+ if should_act; then enter_block; else exit_block; fi
+ sleep "$TETHER_CHECK_INTERVAL"
+done }
+
+ The rest slots the daemon into the existing lifecycle, following each file's own pattern: config.sh flags; focus_ctl.sh gains tether-status/start/stop/log/test-on/off; magisk_service.sh launches it at boot; deploy.sh pushes, installs and restarts it. Finally lib/tests/test_tether_enforcer.sh runs the real daemon against stubbed Android tools and drives it through all five gate states — 17 assertions, and it's the working sibling of the repo's older, now-stale enforcer tests.
Generated as an /explain-diff walkthrough of commit range HEAD~2..HEAD in testsAndMisc. 2026-07-13.
2 explanations · interactive walkthroughs of notable changes, newest first.
+3 explanations · interactive walkthroughs of notable changes, newest first.