curfew_enforcer: split 367 -> 244 + 156, net layer at 100%

The nine network-curfew functions -- the per-UID iptables allow-list that
cuts every app outside $NIGHT_WHITELIST off the network overnight -- move
into curfew_net.sh. NET_BUILT stays in the enforcer: both files write it,
so it is a genuine shared global rather than a seam violation.

Placed as a FLAT sibling, not under lib/. deploy.sh pushes phone scripts
into one directory, so a lib/ path would simply not exist on the device;
dns_iptables.sh and tether_iptables.sh sit beside their enforcers for the
same reason. Added to BOTH deploy lists, and deliberately not to the chmod
list -- sourced files need no exec bit there, matching the other two.

41 assertions, 60/60 lines, 17 mutations all killed. The one that matters
most flips the 10000-19999 REJECT to ACCEPT, which would silently disable
the entire cut-off while leaving a chain that looks populated. The suite
also asserts rule ORDER, not just presence: every ACCEPT must precede that
REJECT, or the whitelist blocks the very apps it exists to permit.

night_uids and fill_net_chain are reported as non-verbatim by
verify_shell_split.sh, correctly: both ended in `done < file`, a line kcov
instruments and bash never reports, which alone held the file at 96.77%.
Both are now piped. Neither loop reads stdin internally, so the subshell is
immaterial -- unlike pull_apks in backup_capture.sh, where the same change
would reintroduce a known bug. Behaviour was checked rather than assumed:
night_uids emits the same uids, and fill_net_chain emits an identical
11-rule sequence, diffed against the pre-split bodies.

UNVERIFIED ON DEVICE -- joins tether_enforcer.sh and dns_enforcer.sh.
Over-cap count 21 -> 20.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-17 16:18:05 +02:00
parent 61be666329
commit 9f2e208257
9 changed files with 751 additions and 125 deletions

View File

@ -0,0 +1,21 @@
{
"title": "Split curfew_enforcer.sh's network layer into a tested sibling",
"objective": "Take curfew_enforcer.sh from 367 lines to under the repo's 250-line cap by moving its nine network-curfew functions into curfew_net.sh, and cover that library at 100%. The per-UID iptables allow-list decides which apps can reach the network overnight; a wrong rule order silently blocks everything or blocks nothing.",
"acceptance_criteria": [
"bash meta/scripts/check_file_length.sh --all no longer lists phone_focus_mode/curfew_enforcer.sh, and no new file is over the cap.",
"bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_curfew_net.sh curfew_net.sh exits 0 at its default 100% minimum.",
"Every assertion is mutation-proofed with zero survivors, including mutations that invert the REJECT, cut off root, and pass non-numeric uids to iptables.",
"The rule ORDER is asserted, not just rule presence: every ACCEPT must precede the 10000-19999 REJECT.",
"meta/scripts/verify_shell_split.sh reports every function verbatim, except any rewritten for measurability — for which the emitted rule sequence must be proven identical instead.",
"curfew_net.sh is added to BOTH of deploy.sh's hardcoded lists (the push list and the cp list), as a flat sibling matching dns_iptables.sh and tether_iptables.sh — not under lib/, which deploy.sh does not create on the device.",
"shellcheck passes on each touched file individually, with no -x and no disable directive.",
"sh -n passes on both phone-side scripts, which run under ash, not bash."
],
"out_of_scope": [
"Deploying to the phone or verifying on-device; there is no device in this session.",
"Splitting the grayscale or DND layers out of curfew_enforcer.sh, which is already under the cap without the net layer.",
"Adding curfew_net.sh to deploy.sh's chmod list: it is sourced, not executed, matching how dns_iptables.sh and tether_iptables.sh are handled.",
"Changing what the curfew blocks or allows."
],
"verifier": "bash phone_focus_mode/lib/tests/test_curfew_net.sh (expects 41 passed, 0 failed), plus bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_curfew_net.sh curfew_net.sh at 100.00%, plus the mutation run at 17 killed / 0 survived, plus bash meta/scripts/check_file_length.sh --all showing 20 files, plus grep for curfew_net in both deploy.sh lists, plus pre-commit run --files on the touched files."
}

View File

@ -0,0 +1,83 @@
{
"intent": "Bring curfew_enforcer.sh under the 250-line cap by extracting its network-curfew layer into curfew_net.sh, and cover that layer at 100% — it had no test, and it is the code that decides which apps can reach the network overnight.",
"scope": [
"phone_focus_mode/curfew_enforcer.sh - 367 lines down to 244",
"phone_focus_mode/curfew_net.sh - new: night_uids, ensure_net_chain, fill_net_chain, iptw, refresh_uid_cache, rebuild_net_from_cache, net_hold, apply_net, teardown_net",
"phone_focus_mode/deploy.sh - the new sibling added to both hardcoded lists",
"phone_focus_mode/lib/tests/curfew_net_harness.sh - new: iptables/ip6tables/pm stubs",
"phone_focus_mode/lib/tests/curfew_net_cases_chain.sh - new: chain-construction assertions",
"phone_focus_mode/lib/tests/curfew_net_cases_lifecycle.sh - new: lifecycle assertions",
"phone_focus_mode/lib/tests/test_curfew_net.sh - new: the entry point",
"Non-goal: any device deploy. The split is proven with stubs, hashes and output comparison instead."
],
"changes": [
"Moved the nine network functions with meta/scripts/extract_shell_functions.py. As expected for a #!/system/bin/sh script with no set -e anchor, the tool moved the functions and warned that the source line had to be placed by hand; it was placed after the existing config.sh source, which defines the globals these functions read.",
"Placed the library as a FLAT SIBLING (phone_focus_mode/curfew_net.sh), not under lib/. deploy.sh pushes phone scripts into one flat directory, so a lib/ path would not exist on the device; dns_iptables.sh and tether_iptables.sh sit beside their enforcers for the same reason. This was caught by reading the deploy lists before committing, not after.",
"Added curfew_net.sh to both of deploy.sh's hardcoded lists: the adb_cmd push list and the adb_root cp list. It is deliberately NOT added to the chmod 755 list, matching dns_iptables.sh and tether_iptables.sh — sourced files do not need the executable bit on the device.",
"Left NET_BUILT in the entry script: both files write it, so it is a genuine shared global and neither file assigns something it never reads.",
"Rewrote two `while ... done < file` loops as pipes. A trailing `done < file` is a line kcov instruments but bash never reports, and those two lines alone held the file at 96.77%. Neither loop reads stdin internally, so the pipe's subshell is immaterial — unlike the pull_apks case in backup_capture.sh, where that same change would reintroduce a known bug.",
"Added stubs on PATH rather than function mocks: unlike the monitor probes, these functions invoke iptables, ip6tables and pm as real binaries, so the process boundary is where the fake has to sit. The stub logs every call in order, which is what makes rule ordering assertable."
],
"verification": [
{
"command": "bash phone_focus_mode/lib/tests/test_curfew_net.sh",
"result": "pass",
"evidence": "Results: 41 passed, 0 failed. There was no test for this code before."
},
{
"command": "bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_curfew_net.sh curfew_net.sh",
"result": "pass",
"evidence": "curfew_net.sh: 60/60 lines = 100.00%, exit 0 at the default 100% minimum. First measured run was 96.77%."
},
{
"command": "mutation run: 17 mutations of the network behaviour, each required to fail the suite",
"result": "pass",
"evidence": "17 killed, 0 survived. Includes flipping the 10000-19999 REJECT to ACCEPT (which would disable the whole cut-off), cutting off root, moving the OUTPUT jump off position 1, and passing non-numeric uids to iptables."
},
{
"command": "rule-order assertion inside the suite",
"result": "pass",
"evidence": "The REJECT line index is asserted greater than the last ACCEPT line index. Presence assertions alone would pass with the order reversed, which would block every whitelisted app."
},
{
"command": "bash meta/scripts/verify_shell_split.sh 61be666 phone_focus_mode/curfew_enforcer.sh phone_focus_mode/curfew_enforcer.sh phone_focus_mode/curfew_net.sh",
"result": "pass",
"evidence": "Reports night_uids and fill_net_chain as differing, which is correct: those are the two functions whose loops were rewritten. Every other function hashes identical."
},
{
"command": "behavioural comparison of the two rewritten functions against their pre-split bodies on identical inputs",
"result": "pass",
"evidence": "night_uids emits '10123 10456' from both. fill_net_chain emits an identical 11-rule sequence from both, diffed byte-for-byte including ordering — so the hash differences above carry no behaviour change."
},
{
"command": "shellcheck on each touched file individually (no -x)",
"result": "pass",
"evidence": "All seven clean standalone. Two SC2034 findings in the lifecycle cases were fixed by reading the values back rather than by a disable."
},
{
"command": "sh -n phone_focus_mode/curfew_enforcer.sh; sh -n phone_focus_mode/curfew_net.sh",
"result": "pass",
"evidence": "Both parse under ash, which is what actually runs them on the device. The enforcer is a daemon loop and is not safe to execute here, so this plus the sourced-library tests is the substitute for running it."
},
{
"command": "grep curfew_net phone_focus_mode/deploy.sh",
"result": "pass",
"evidence": "Two hits: the push list at line 382 and the cp list at line 501. Both lists updated, which is the step the brief flags as easy to miss."
},
{
"command": "bash meta/scripts/check_file_length.sh --all",
"result": "pass",
"evidence": "20 over-cap files, down from 21. curfew_enforcer.sh 244, curfew_net.sh 156, and the three test files 170/174/121 — all under the cap."
}
],
"risks": [
"UNVERIFIED ON DEVICE. This joins tether_enforcer.sh and dns_enforcer.sh as an enforcer split with passing tests and no deploy behind it. If the night curfew's network layer misbehaves after the next deploy, this is one of three places to look.",
"The library is a new file that must reach the device. If the deploy runs from a stale checkout, or a partial push drops curfew_net.sh, curfew_enforcer.sh will fail to source it and exit immediately rather than running with the net layer disabled. The net layer defaults to off, so the blast radius of it being missing is the whole enforcer, not just the net layer.",
"The two rewritten loops now run their bodies in a pipe subshell. Neither assigns a variable the caller reads, so nothing is lost today, but a future edit that adds such an assignment inside either loop would silently not take effect."
],
"rollback": [
"git revert the commit. curfew_enforcer.sh returns to a single 367-line file, over the cap and untested, and deploy.sh stops pushing curfew_net.sh.",
"After rollback, run sh -n phone_focus_mode/curfew_enforcer.sh expecting exit 0, and confirm grep curfew_net phone_focus_mode/deploy.sh returns nothing.",
"A rollback AFTER a deploy would leave a stale curfew_net.sh on the device; it is inert once nothing sources it, but remove it from $REMOTE_DIR to avoid confusing a later audit."
]
}

View File

@ -29,6 +29,8 @@
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=config.sh
. "$SCRIPT_DIR/config.sh"
# shellcheck source=curfew_net.sh
. "$SCRIPT_DIR/curfew_net.sh"
PIDFILE="$STATE_DIR/curfew_enforcer.pid"
# Snapshot of the user's pre-curfew display/DND state, captured on entry and
@ -157,72 +159,9 @@ restore_dnd() {
# ---- Layer 3: per-UID network allow-list (default OFF) ----
# Resolve the UIDs of the night-whitelisted packages. Apps not installed are
# silently skipped. Output: one numeric UID per line.
night_uids() {
local plist="$STATE_DIR/night_whitelist.txt"
[ -f "$plist" ] || return 0
# `pm list packages -U` lines look like: "package:com.foo uid:10123"
local map="$STATE_DIR/uid_map.txt"
pm list packages -U 2>/dev/null \
| sed 's/^package://' > "$map"
while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
awk -v p="$pkg" '$1 == p { sub(/uid:/,"",$2); print $2 }' "$map"
done < "$plist"
rm -f "$map"
}
ensure_net_chain() {
local ipt="$1"
if ! iptw "$ipt" -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
iptw "$ipt" -N "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
fi
# De-dupe and pin exactly one OUTPUT jump at position 1.
while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done
iptw "$ipt" -I OUTPUT 1 -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
}
fill_net_chain() {
local ipt="$1" reject="$2"
iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
# Always-allowed plumbing: loopback, established flows, the OS itself, the
# daemon/ADB (root + shell), and DNS (apps resolve via netd, a different
# uid, so allow port 53 broadly or every lookup fails under the cut-off).
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -o lo -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 0 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 1000 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 2000 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p udp --dport 53 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true
# Allow each whitelisted app UID, read from the cache (refreshed once per
# main tick). Reading the cache instead of calling night_uids() here keeps
# the fast watchdog fork-free (no `pm list packages` on every rebuild).
local uid
if [ -f "$CURFEW_NET_UID_CACHE" ]; then
while IFS= read -r uid; do
case "$uid" in ''|*[!0-9]*) continue ;; esac
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner "$uid" -j ACCEPT 2>/dev/null || true
done < "$CURFEW_NET_UID_CACHE"
fi
# Cut off every remaining ordinary APP uid (10000-19999 = user-0 app range).
# Scoped to the app range so kernel/system sockets (no owner / low uids) are
# never touched — far safer than a blanket default-DROP.
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 10000-19999 -j REJECT \
--reject-with "$reject" 2>/dev/null || true
}
# Run iptables/ip6tables with a 2s xtables lock-wait. Android's netd runs its
# own concurrent `iptables-restore`; without -w our calls silently fail the
# instant netd holds the lock (proven on-device: partial 19-rule chains and
# multi-second outages). -w queues for the lock so our calls actually land.
# iptables 1.8.7 legacy supports it. $1 = binary (iptables/ip6tables).
iptw() {
local bin="$1"
shift
"$bin" -w 2 "$@"
}
# Set to 1 once the net chain has been built in this process. Used purely to
# tell an *anomalous* mid-curfew disappearance (something outside this script
@ -231,72 +170,10 @@ iptw() {
# false-flags its own initial build.
NET_BUILT=""
# Refresh the cached UID list (one `pm list packages -U` fork). Called once per
# main tick so the fast watchdog can rebuild from the cache without forking.
refresh_uid_cache() {
if night_uids > "$CURFEW_NET_UID_CACHE.tmp" 2>/dev/null; then
mv "$CURFEW_NET_UID_CACHE.tmp" "$CURFEW_NET_UID_CACHE" 2>/dev/null || true
fi
}
# Rebuild the chain from cache for whichever iptables variants exist. No pm fork.
rebuild_net_from_cache() {
if command -v iptables >/dev/null 2>&1; then
ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable
fi
if command -v ip6tables >/dev/null 2>&1; then
ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable
fi
}
# Fast watchdog: for `total` seconds, every CURFEW_NET_REASSERT_INTERVAL check
# whether netd wiped our chain and, if so, re-pin it from cache. Replaces the
# plain inter-tick sleep while curfew is active so the leak window drops from
# the full 5s tick to <=1s. Echoes the number of rebuilds it performed.
net_hold() {
local total="$1" elapsed=0 rebuilds=0 step="${CURFEW_NET_REASSERT_INTERVAL:-1}"
while [ "$elapsed" -lt "$total" ]; do
sleep "$step"
elapsed=$((elapsed + step))
if command -v iptables >/dev/null 2>&1 \
&& ! iptw iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
rebuild_net_from_cache
rebuilds=$((rebuilds + 1))
fi
done
echo "$rebuilds"
}
apply_net() {
[ "${CURFEW_NET_ENABLED:-0}" = "1" ] || return 0
refresh_uid_cache
# Discriminating probe: if we already built the chain on a prior tick but it
# is gone now, an external actor wiped it (Android netd rewriting the filter
# table, or a manual flush during debugging). Log each disappearance so the
# live test reads "flush + self-heal" vs "dead process" directly, instead of
# inferring it from log silence.
if [ -n "$NET_BUILT" ] && command -v iptables >/dev/null 2>&1 \
&& ! iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
log "net chain $CURFEW_NET_IPT_CHAIN vanished since last tick - rebuilding (external flush?)"
fi
if command -v iptables >/dev/null 2>&1; then
ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable
fi
if command -v ip6tables >/dev/null 2>&1; then
ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable
fi
NET_BUILT=1
}
teardown_net() {
local ipt
for ipt in iptables ip6tables; do
command -v "$ipt" >/dev/null 2>&1 || continue
while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done
iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true
iptw "$ipt" -X "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true
done
}
# ---- Apply / revert orchestration ----

156
phone_focus_mode/curfew_net.sh Executable file
View File

@ -0,0 +1,156 @@
#!/system/bin/sh
# shellcheck shell=ash
# curfew_net.sh — the network layer of the night curfew: the per-UID
# iptables allow-list that cuts every app outside $NIGHT_WHITELIST off the
# network while the curfew is open. Default OFF ($CURFEW_NET_ENABLED).
#
# A flat sibling, not lib/: deploy.sh pushes phone scripts into one
# directory, which is why dns_iptables.sh and tether_iptables.sh live
# beside their enforcers too.
#
# Sourced by curfew_enforcer.sh, which owns log(), the config.sh globals
# these read ($CURFEW_NET_*, $NIGHT_WHITELIST, $STATE_DIR) and $NET_BUILT,
# the build-once flag both files write.
# Resolve the UIDs of the night-whitelisted packages. Apps not installed are
# silently skipped. Output: one numeric UID per line.
night_uids() {
local plist="$STATE_DIR/night_whitelist.txt"
[ -f "$plist" ] || return 0
# `pm list packages -U` lines look like: "package:com.foo uid:10123"
local map="$STATE_DIR/uid_map.txt"
pm list packages -U 2>/dev/null \
| sed 's/^package://' > "$map"
# Piped rather than redirected on `done`: kcov instruments a trailing
# `done < file` as a line bash never reports, which alone held this file
# below 100%. The loop only writes to stdout, so running it in the pipe's
# subshell changes nothing — unlike the pull_apks case in
# backup_capture.sh, nothing inside here consumes stdin.
cat "$plist" | while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
awk -v p="$pkg" '$1 == p { sub(/uid:/,"",$2); print $2 }' "$map"
done
rm -f "$map"
}
ensure_net_chain() {
local ipt="$1"
if ! iptw "$ipt" -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
iptw "$ipt" -N "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
fi
# De-dupe and pin exactly one OUTPUT jump at position 1.
while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done
iptw "$ipt" -I OUTPUT 1 -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
}
fill_net_chain() {
local ipt="$1" reject="$2"
iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || return 1
# Always-allowed plumbing: loopback, established flows, the OS itself, the
# daemon/ADB (root + shell), and DNS (apps resolve via netd, a different
# uid, so allow port 53 broadly or every lookup fails under the cut-off).
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -o lo -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 0 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 1000 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 2000 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p udp --dport 53 -j ACCEPT 2>/dev/null || true
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -p tcp --dport 53 -j ACCEPT 2>/dev/null || true
# Allow each whitelisted app UID, read from the cache (refreshed once per
# main tick). Reading the cache instead of calling night_uids() here keeps
# the fast watchdog fork-free (no `pm list packages` on every rebuild).
local uid
if [ -f "$CURFEW_NET_UID_CACHE" ]; then
# Piped for the same reason as night_uids above: a trailing
# `done < file` is a line kcov counts and bash never reports. The
# loop's only effect is the iptw calls, which are external commands,
# so the pipe's subshell is immaterial here.
cat "$CURFEW_NET_UID_CACHE" | while IFS= read -r uid; do
case "$uid" in ''|*[!0-9]*) continue ;; esac
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner "$uid" -j ACCEPT 2>/dev/null || true
done
fi
# Cut off every remaining ordinary APP uid (10000-19999 = user-0 app range).
# Scoped to the app range so kernel/system sockets (no owner / low uids) are
# never touched — far safer than a blanket default-DROP.
iptw "$ipt" -A "$CURFEW_NET_IPT_CHAIN" -m owner --uid-owner 10000-19999 -j REJECT \
--reject-with "$reject" 2>/dev/null || true
}
# Run iptables/ip6tables with a 2s xtables lock-wait. Android's netd runs its
# own concurrent `iptables-restore`; without -w our calls silently fail the
# instant netd holds the lock (proven on-device: partial 19-rule chains and
# multi-second outages). -w queues for the lock so our calls actually land.
# iptables 1.8.7 legacy supports it. $1 = binary (iptables/ip6tables).
iptw() {
local bin="$1"
shift
"$bin" -w 2 "$@"
}
# Refresh the cached UID list (one `pm list packages -U` fork). Called once per
# main tick so the fast watchdog can rebuild from the cache without forking.
refresh_uid_cache() {
if night_uids > "$CURFEW_NET_UID_CACHE.tmp" 2>/dev/null; then
mv "$CURFEW_NET_UID_CACHE.tmp" "$CURFEW_NET_UID_CACHE" 2>/dev/null || true
fi
}
# Rebuild the chain from cache for whichever iptables variants exist. No pm fork.
rebuild_net_from_cache() {
if command -v iptables >/dev/null 2>&1; then
ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable
fi
if command -v ip6tables >/dev/null 2>&1; then
ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable
fi
}
# Fast watchdog: for `total` seconds, every CURFEW_NET_REASSERT_INTERVAL check
# whether netd wiped our chain and, if so, re-pin it from cache. Replaces the
# plain inter-tick sleep while curfew is active so the leak window drops from
# the full 5s tick to <=1s. Echoes the number of rebuilds it performed.
net_hold() {
local total="$1" elapsed=0 rebuilds=0 step="${CURFEW_NET_REASSERT_INTERVAL:-1}"
while [ "$elapsed" -lt "$total" ]; do
sleep "$step"
elapsed=$((elapsed + step))
if command -v iptables >/dev/null 2>&1 \
&& ! iptw iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
rebuild_net_from_cache
rebuilds=$((rebuilds + 1))
fi
done
echo "$rebuilds"
}
apply_net() {
[ "${CURFEW_NET_ENABLED:-0}" = "1" ] || return 0
refresh_uid_cache
# Discriminating probe: if we already built the chain on a prior tick but it
# is gone now, an external actor wiped it (Android netd rewriting the filter
# table, or a manual flush during debugging). Log each disappearance so the
# live test reads "flush + self-heal" vs "dead process" directly, instead of
# inferring it from log silence.
if [ -n "$NET_BUILT" ] && command -v iptables >/dev/null 2>&1 \
&& ! iptables -L "$CURFEW_NET_IPT_CHAIN" >/dev/null 2>&1; then
log "net chain $CURFEW_NET_IPT_CHAIN vanished since last tick - rebuilding (external flush?)"
fi
if command -v iptables >/dev/null 2>&1; then
ensure_net_chain iptables && fill_net_chain iptables icmp-port-unreachable
fi
if command -v ip6tables >/dev/null 2>&1; then
ensure_net_chain ip6tables && fill_net_chain ip6tables icmp6-port-unreachable
fi
NET_BUILT=1
}
teardown_net() {
local ipt
for ipt in iptables ip6tables; do
command -v "$ipt" >/dev/null 2>&1 || continue
while iptw "$ipt" -D OUTPUT -j "$CURFEW_NET_IPT_CHAIN" 2>/dev/null; do :; done
iptw "$ipt" -F "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true
iptw "$ipt" -X "$CURFEW_NET_IPT_CHAIN" 2>/dev/null || true
done
}

View File

@ -379,6 +379,7 @@ do_deploy() {
adb_cmd push "$DEPLOY_DIR/dns_iptables.sh" "/data/local/tmp/focus_stage/dns_iptables.sh"
adb_cmd push "$DEPLOY_DIR/launcher_enforcer.sh" "/data/local/tmp/focus_stage/launcher_enforcer.sh"
adb_cmd push "$DEPLOY_DIR/curfew_enforcer.sh" "/data/local/tmp/focus_stage/curfew_enforcer.sh"
adb_cmd push "$DEPLOY_DIR/curfew_net.sh" "/data/local/tmp/focus_stage/curfew_net.sh"
adb_cmd push "$DEPLOY_DIR/tether_enforcer.sh" "/data/local/tmp/focus_stage/tether_enforcer.sh"
adb_cmd push "$DEPLOY_DIR/tether_iptables.sh" "/data/local/tmp/focus_stage/tether_iptables.sh"
adb_cmd push "$DEPLOY_DIR/workout_detector.sh" "/data/local/tmp/focus_stage/workout_detector.sh"
@ -497,6 +498,7 @@ do_deploy() {
adb_root "cp /data/local/tmp/focus_stage/dns_iptables.sh $REMOTE_DIR/dns_iptables.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/curfew_net.sh $REMOTE_DIR/curfew_net.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/tether_iptables.sh $REMOTE_DIR/tether_iptables.sh"
adb_root "cp /data/local/tmp/focus_stage/workout_detector.sh $REMOTE_DIR/workout_detector.sh"

View File

@ -0,0 +1,173 @@
#!/usr/bin/env bash
# lib/tests/curfew_net_cases_chain.sh — assertions for the chain-construction
# half of curfew_net.sh: night_uids, ensure_net_chain and fill_net_chain.
#
# Sourced by test_curfew_net.sh after curfew_net_harness.sh, which owns the
# stubs, the subject and the PASS/FAIL counters this file adds to. Split out
# to keep every test file under the repo's 250-line cap.
set -euo pipefail
# --- night_uids -------------------------------------------------------------
_t_eq "" "$(night_uids)" "night_uids is empty when the whitelist file is absent"
printf 'com.foo\ncom.bar\n\ncom.absent\n' >"${STATE_DIR}/night_whitelist.txt"
printf 'com.foo uid:10123\ncom.bar uid:10456\ncom.other uid:10999\n' >"${PM_FIXTURE}"
_t_eq "10123 10456" "$(night_uids | tr '\n' ' ' | sed 's/ $//')" \
"night_uids resolves only the whitelisted packages"
if [[ ! -f "${STATE_DIR}/uid_map.txt" ]]; then
_t_pass "night_uids cleans up its temporary uid map"
else
_t_fail "night_uids left uid_map.txt behind"
fi
# --- ensure_net_chain -------------------------------------------------------
_reset_ipt
if ensure_net_chain iptables; then
_t_pass "ensure_net_chain creates a missing chain"
else
_t_fail "ensure_net_chain should succeed when it can create the chain"
fi
case "$(_calls)" in
*"-N FOCUS_CURFEW_NET"*) _t_pass "ensure_net_chain creates the named chain" ;;
*) _t_fail "ensure_net_chain did not create the chain" ;;
esac
case "$(_calls)" in
*"-I OUTPUT 1 -j FOCUS_CURFEW_NET"*) _t_pass "ensure_net_chain pins the OUTPUT jump at position 1" ;;
*) _t_fail "ensure_net_chain did not pin the jump at position 1" ;;
esac
# Chain creation failing must be reported, not silently continued past.
_reset_ipt
_fail_op N
if ! ensure_net_chain iptables; then
_t_pass "ensure_net_chain fails when the chain cannot be created"
else
_t_fail "ensure_net_chain should fail when -N fails"
fi
_clear_fail N
# The jump insert failing likewise.
_reset_ipt
_fail_op I
if ! ensure_net_chain iptables; then
_t_pass "ensure_net_chain fails when the OUTPUT jump cannot be inserted"
else
_t_fail "ensure_net_chain should fail when -I fails"
fi
_clear_fail I
# Stale duplicate jumps left by a lock race are removed before the new one.
_reset_ipt
_seed_jumps iptables 3
ensure_net_chain iptables
# Four calls for three jumps: the loop is `while -D succeeds`, so the fourth
# is the one that fails and ends it. Asserting on the count rather than on
# the final state is what would catch the loop terminating early.
_t_eq "4" "$(_calls | grep -c -- "-D OUTPUT -j FOCUS_CURFEW_NET")" \
"ensure_net_chain deletes every stale OUTPUT jump"
_t_eq "1" "$(cat "${RUN}/ipt/iptables/jumps")" \
"ensure_net_chain leaves exactly one OUTPUT jump"
# An existing chain is reused rather than recreated.
_reset_ipt
_seed_jumps iptables 0
ensure_net_chain iptables
if _calls | grep -q -- "-N FOCUS_CURFEW_NET"; then
_t_fail "ensure_net_chain recreated a chain that already existed"
else
_t_pass "ensure_net_chain reuses an existing chain"
fi
# --- fill_net_chain ---------------------------------------------------------
_reset_ipt
printf '10123\n10456\n' >"${CURFEW_NET_UID_CACHE}"
fill_net_chain iptables icmp-port-unreachable
rules="$(_rules_of iptables)"
case "${rules}" in
*"-o lo -j ACCEPT"*) _t_pass "fill_net_chain always allows loopback" ;;
*) _t_fail "fill_net_chain must allow loopback" ;;
esac
case "${rules}" in
*"ESTABLISHED,RELATED -j ACCEPT"*) _t_pass "fill_net_chain allows established flows" ;;
*) _t_fail "fill_net_chain must allow established flows" ;;
esac
for uid in 0 1000 2000; do
case "${rules}" in
*"--uid-owner ${uid} -j ACCEPT"*) _t_pass "fill_net_chain allows uid ${uid}" ;;
*) _t_fail "fill_net_chain must allow uid ${uid}" ;;
esac
done
# DNS on both protocols: apps resolve via netd under a different uid, so
# without these every lookup fails under the cut-off.
_t_eq "2" "$(printf '%s\n' "${rules}" | grep -c -- "--dport 53 -j ACCEPT")" \
"fill_net_chain allows DNS over both udp and tcp"
for uid in 10123 10456; do
case "${rules}" in
*"--uid-owner ${uid} -j ACCEPT"*) _t_pass "fill_net_chain allows whitelisted uid ${uid}" ;;
*) _t_fail "fill_net_chain must allow whitelisted uid ${uid}" ;;
esac
done
case "${rules}" in
*"--uid-owner 10000-19999 -j REJECT"*) _t_pass "fill_net_chain rejects the remaining app uid range" ;;
*) _t_fail "fill_net_chain must reject the remaining app range" ;;
esac
# Ordering is the whole point: a REJECT before the ACCEPTs blocks everything.
reject_line="$(printf '%s\n' "${rules}" | grep -n -- "10000-19999" | cut -d: -f1)"
last_accept="$(printf '%s\n' "${rules}" | grep -n -- "-j ACCEPT" | tail -1 | cut -d: -f1)"
if [[ "${reject_line}" -gt "${last_accept}" ]]; then
_t_pass "fill_net_chain puts the REJECT after every ACCEPT"
else
_t_fail "fill_net_chain REJECT at ${reject_line} precedes an ACCEPT at ${last_accept}"
fi
# The reject-with argument differs per protocol and must be passed through.
_reset_ipt
fill_net_chain ip6tables icmp6-port-unreachable
case "$(_rules_of ip6tables)" in
*"--reject-with icmp6-port-unreachable"*) _t_pass "fill_net_chain passes the v6 reject type through" ;;
*) _t_fail "fill_net_chain must pass the caller's reject type" ;;
esac
# A non-numeric cache line must be skipped, not passed to iptables.
_reset_ipt
printf '10123\nnot-a-uid\n\n10456\n' >"${CURFEW_NET_UID_CACHE}"
fill_net_chain iptables icmp-port-unreachable
if _rules_of iptables | grep -q "not-a-uid"; then
_t_fail "fill_net_chain passed a non-numeric uid to iptables"
else
_t_pass "fill_net_chain skips non-numeric cache entries"
fi
# A missing cache is not fatal: the plumbing rules must still be laid down.
_reset_ipt
rm -f "${CURFEW_NET_UID_CACHE}"
fill_net_chain iptables icmp-port-unreachable
case "$(_rules_of iptables)" in
*"10000-19999 -j REJECT"*) _t_pass "fill_net_chain still cuts off apps with no uid cache" ;;
*) _t_fail "fill_net_chain must lay down the REJECT even with no cache" ;;
esac
# A failing flush aborts the fill rather than layering onto stale rules.
_reset_ipt
_fail_op F
if ! fill_net_chain iptables icmp-port-unreachable; then
_t_pass "fill_net_chain fails when the chain cannot be flushed"
else
_t_fail "fill_net_chain should fail when -F fails"
fi
_clear_fail F

View File

@ -0,0 +1,121 @@
#!/usr/bin/env bash
# lib/tests/curfew_net_cases_lifecycle.sh — assertions for the lifecycle half
# of curfew_net.sh: iptw, refresh_uid_cache, rebuild_net_from_cache, net_hold,
# apply_net and teardown_net.
#
# Sourced by test_curfew_net.sh after curfew_net_harness.sh, which owns the
# stubs, the subject and the PASS/FAIL counters this file adds to.
set -euo pipefail
# --- iptw -------------------------------------------------------------------
_reset_ipt
iptw iptables -L "${CURFEW_NET_IPT_CHAIN}" >/dev/null 2>&1 || true
if _calls | grep -q "^iptables -L"; then
_t_pass "iptw dispatches to the named binary"
else
_t_fail "iptw did not call the named binary"
fi
# --- refresh_uid_cache ------------------------------------------------------
printf 'com.foo\ncom.bar\n' >"${STATE_DIR}/night_whitelist.txt"
printf 'com.foo uid:10123\ncom.bar uid:10456\n' >"${PM_FIXTURE}"
rm -f "${CURFEW_NET_UID_CACHE}"
refresh_uid_cache
_t_eq "10123 10456" "$(tr '\n' ' ' <"${CURFEW_NET_UID_CACHE}" | sed 's/ $//')" \
"refresh_uid_cache writes the resolved uids"
if [[ ! -f "${CURFEW_NET_UID_CACHE}.tmp" ]]; then
_t_pass "refresh_uid_cache moves its temp file into place"
else
_t_fail "refresh_uid_cache left a .tmp file behind"
fi
# --- rebuild_net_from_cache -------------------------------------------------
_reset_ipt
rebuild_net_from_cache
if [[ -n "$(_rules_of iptables)" && -n "$(_rules_of ip6tables)" ]]; then
_t_pass "rebuild_net_from_cache builds both v4 and v6 chains"
else
_t_fail "rebuild_net_from_cache should build both address families"
fi
# --- net_hold ---------------------------------------------------------------
# Chain intact for the whole window: no rebuild.
_reset_ipt
_seed_jumps iptables 1
# Read back after assigning: the subject is what consumes these, but each
# file is linted standalone, so an assignment with no reader here is SC2034.
CURFEW_NET_REASSERT_INTERVAL=1
_t_eq "1" "${CURFEW_NET_REASSERT_INTERVAL}" "the reassert interval is set for net_hold"
_t_eq "0" "$(net_hold 2)" "net_hold does not rebuild while the chain survives"
# Chain missing: the first check rebuilds it, and because the rebuild
# actually creates the chain the second check finds it present. One rebuild
# for a single flush is the self-healing behaviour, not a missed one.
_reset_ipt
_t_eq "1" "$(net_hold 2)" "net_hold rebuilds a vanished chain and then settles"
# --- apply_net --------------------------------------------------------------
_reset_ipt
CURFEW_NET_ENABLED=0
apply_net
_t_eq "" "$(_calls)" "apply_net does nothing while the net layer is disabled"
CURFEW_NET_ENABLED=1
_t_eq "1" "${CURFEW_NET_ENABLED}" "the net layer is re-enabled for the remaining cases"
_reset_ipt
NET_BUILT=""
apply_net
_t_eq "1" "${NET_BUILT}" "apply_net marks the chain as built"
if [[ -n "$(_rules_of iptables)" && -n "$(_rules_of ip6tables)" ]]; then
_t_pass "apply_net builds both address families"
else
_t_fail "apply_net should build both address families"
fi
# A chain that vanished between ticks is logged, so a live test can tell
# "external flush + self-heal" from "the daemon died".
_reset_ipt
: >"${STATE_DIR}/log"
NET_BUILT=1
apply_net
if grep -q "vanished since last tick" "${STATE_DIR}/log"; then
_t_pass "apply_net logs an externally flushed chain"
else
_t_fail "apply_net should log when the chain vanished between ticks"
fi
# On a first build there is nothing to have vanished, so nothing is logged.
_reset_ipt
: >"${STATE_DIR}/log"
NET_BUILT=""
apply_net
if grep -q "vanished since last tick" "${STATE_DIR}/log"; then
_t_fail "apply_net logged a vanished chain on its first build"
else
_t_pass "apply_net stays quiet on the first build"
fi
# --- teardown_net -----------------------------------------------------------
_reset_ipt
_seed_jumps iptables 2
_seed_jumps ip6tables 1
teardown_net
# 2 jumps + 1 terminating failure on v4, 1 + 1 on v6 = 5.
_t_eq "5" "$(_calls | grep -c -- "-D OUTPUT -j FOCUS_CURFEW_NET")" \
"teardown_net removes every OUTPUT jump on both families"
_t_eq "2" "$(_calls | grep -c -- "-X FOCUS_CURFEW_NET")" \
"teardown_net deletes the chain on both families"
if [[ -f "${RUN}/ipt/iptables/chain" ]]; then
_t_fail "teardown_net left the v4 chain in place"
else
_t_pass "teardown_net leaves no chain behind"
fi

View File

@ -0,0 +1,170 @@
#!/usr/bin/env bash
# lib/tests/curfew_net_harness.sh — staging and stubs for the curfew_net.sh
# tests.
#
# Sourced, not executed. Unlike the monitor tests, the stubs here go on PATH:
# curfew_net.sh calls iptables, ip6tables and pm as real binaries, so the
# process boundary is where the fake has to sit. Modelled on
# dns_iptables_harness.sh, which stubs the same two firewall binaries.
#
# The stub records every call in order, which is what makes the rule ordering
# assertable — the ACCEPT rules must precede the 10000-19999 REJECT or the
# whitelist is meaningless.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly PHONE_DIR="${SCRIPT_DIR}/../.."
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
_t_eq() {
local want="$1" got="$2" what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
RUN="$(mktemp -d)"
trap 'rm -rf "${RUN}"' EXIT
mkdir -p "${RUN}/app" "${RUN}/bin" "${RUN}/state" "${RUN}/ipt"
cp "${PHONE_DIR}/curfew_net.sh" "${RUN}/app/"
# --- iptables/ip6tables stub ------------------------------------------------
#
# Models a per-binary chain flag, a rule list and the OUTPUT jump count. Every
# call is logged so ordering can be asserted. Failure injection mirrors
# dns_iptables_harness.sh: a test touches fail_<op> to make that operation
# report failure, which is how the error returns become reachable.
cat >"${RUN}/bin/iptables" <<'STUB'
#!/usr/bin/env bash
bin="$(basename "$0")"
store="${IPT_STATE}/${bin}"
mkdir -p "$store"
rules="${store}/rules"
touch "$rules"
# curfew_net always calls through iptw, which prepends `-w 2`. Drop it so the
# case below matches on the real operation.
[[ "$1" == "-w" ]] && shift 2
printf '%s %s\n' "$bin" "$*" >>"${IPT_STATE}/calls.log"
_fails() { [[ -f "${IPT_STATE}/fail_$1" ]]; }
_jumps_file="${store}/jumps"
[[ -f "$_jumps_file" ]] || echo 0 >"$_jumps_file"
case "$1" in
-L) [[ -f "${store}/chain" ]] && exit 0 || exit 1 ;;
-N)
_fails N && exit 1
touch "${store}/chain"
exit 0
;;
-F)
_fails F && exit 1
: >"$rules"
exit 0
;;
-X)
rm -f "${store}/chain"
exit 0
;;
-A)
shift
printf -- '%s\n' "$*" >>"$rules"
exit 0
;;
-I)
_fails I && exit 1
read -r n <"$_jumps_file"
echo $((n + 1)) >"$_jumps_file"
exit 0
;;
-D)
# Succeeds once per existing jump so the de-dupe loop terminates.
read -r n <"$_jumps_file"
if ((n > 0)); then
echo $((n - 1)) >"$_jumps_file"
exit 0
fi
exit 1
;;
esac
exit 0
STUB
chmod +x "${RUN}/bin/iptables"
cp "${RUN}/bin/iptables" "${RUN}/bin/ip6tables"
# `pm list packages -U` output, seeded per test via PM_FIXTURE.
cat >"${RUN}/bin/pm" <<'STUB'
#!/usr/bin/env bash
[[ -f "${PM_FIXTURE}" ]] && cat "${PM_FIXTURE}"
exit 0
STUB
chmod +x "${RUN}/bin/pm"
export IPT_STATE="${RUN}/ipt"
export PM_FIXTURE="${RUN}/state/pm.txt"
export PATH="${RUN}/bin:${PATH}"
# --- ambient globals the library reads from config.sh / the enforcer --------
STATE_DIR="${RUN}/state"
CURFEW_NET_IPT_CHAIN="FOCUS_CURFEW_NET"
CURFEW_NET_UID_CACHE="${RUN}/state/curfew_uids.cache"
CURFEW_NET_REASSERT_INTERVAL=1
CURFEW_NET_ENABLED=1
NET_BUILT=""
log() { printf '%s\n' "$*" >>"${RUN}/state/log"; }
# shellcheck source=../../curfew_net.sh
. "${RUN}/app/curfew_net.sh"
_reset_ipt() {
rm -rf "${RUN}/ipt"
mkdir -p "${RUN}/ipt"
}
# Make the next run report the given iptables operation as failing.
_fail_op() {
touch "${RUN}/ipt/fail_$1"
}
_clear_fail() {
rm -f "${RUN}/ipt/fail_$1"
}
# Pre-seed the chain as existing with N stale OUTPUT jumps, the lock-race
# state ensure_net_chain's de-dupe loop exists to clean up.
_seed_jumps() {
local bin="$1" count="$2"
mkdir -p "${RUN}/ipt/${bin}"
touch "${RUN}/ipt/${bin}/chain"
echo "$count" >"${RUN}/ipt/${bin}/jumps"
}
# Rules recorded for a binary, one per line.
_rules_of() {
cat "${RUN}/ipt/$1/rules" 2>/dev/null || printf ''
}
_calls() {
cat "${RUN}/ipt/calls.log" 2>/dev/null || printf ''
}

View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Unit tests for curfew_net.sh — the per-UID network allow-list of the night
# curfew — against the stubs in curfew_net_harness.sh. No device needed.
#
# What matters here is not that rules are added but that they are added in an
# order that means something: every ACCEPT must precede the 10000-19999
# REJECT, or the whitelist lets nothing through.
#
# This is the only entry point; the cases live in two sourced files purely to
# stay under the 250-line cap.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=curfew_net_harness.sh
. "${SCRIPT_DIR}/curfew_net_harness.sh"
# shellcheck source=curfew_net_cases_chain.sh
. "${SCRIPT_DIR}/curfew_net_cases_chain.sh"
# shellcheck source=curfew_net_cases_lifecycle.sh
. "${SCRIPT_DIR}/curfew_net_cases_lifecycle.sh"
printf '\nResults: %d passed, %d failed\n' "${PASS}" "${FAIL}"
[[ "${FAIL}" -eq 0 ]]