phone_focus_mode · shell / netfilter

Closing the hotspot bypass

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.

1 · Background

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.

OUTPUT
Packets this phone originates — its own apps talking to the internet.
INPUT
Packets addressed to this phone.
FORWARD
Packets merely routed through this phone — the tethering / NAT path.

Here is what each existing layer actually inspects, straight from the scripts:

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.

2 · Intuition

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.

before
📱 2nd phone
wants youtube.com
→
🔁 FORWARD
no rules here — packet sails through
→
🌐 internet
reached. bypass complete.

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:

1 · offload off
Disable Android's tether hardware offload so forwarded packets actually reach netfilter.
2 · FORWARD reject
Insert a blanket REJECT at the top of FORWARD.
3 · stop softAP
Best-effort tear the hotspot down so it visibly flips off.
after
📱 2nd phone
wants youtube.com
→
🔁 FORWARD
FOCUS_TETHER_BLOCK is rule #1 → REJECT
→
🚫 dropped
no internet. bypass closed.

Two subtleties make or break this, and both are addressed in the code:

3 · Play: walk a packet through FORWARD

Pick 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.

4 · Code

~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.

a · The gate — when to act

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.

b · The three levers

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
}

c · Orchestration — reversible by construction

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 }

d · Wiring & proof

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.

5 · Quiz

Score: 0 / 0

Generated as an /explain-diff walkthrough of commit range HEAD~2..HEAD in testsAndMisc. 2026-07-13.