mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-11 21:54:00 +01:00
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
268 lines
8.8 KiB
Bash
Executable File
268 lines
8.8 KiB
Bash
Executable File
#!/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 "$@"
|