🚀 Sync 2026-09-28 12:35:46

This commit is contained in:
github-actions[bot] committed 2026-09-28 12:35:46 +08:00
1 parent 8b304e4854
commit 86e1d0663a
155 files changed
-172498

No files matched your search

@@ -1,260 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Runtime configuration-transaction tests.
#
# These cover the pieces init.d/homeproxy relies on so that a bad
# configuration cannot leave the router without a working service:
#
# * the known-good copy recorded after a configuration passed check
# * the fallback to that copy when a regeneration produces nothing
# * the rollback after a reload that comes up unhealthy
# * the health probe that decides whether a rollback is needed
#
# The helpers are pure shell on purpose (see scripts/runtime/), so this runs
# anywhere, without ucode, procd or a router.
#
# Usage: sh tests/runtime/test_config_transaction.sh <repo-root>
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
RUNTIME="$ROOT/root/etc/homeproxy/scripts/runtime"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/hp-runtime.XXXXXX")" || exit 1
trap 'rm -rf "$WORK"' EXIT INT TERM
FAILED=0
CHECKS=0
FAILURES=0
expect() {
# expect <name> <actual> <expected>
CHECKS=$((CHECKS + 1))
if [ "$2" = "$3" ]; then
echo "PASS: $1"
else
echo "FAIL: $1 (expected '$3', got '$2')"
FAILED=1
FAILURES=$((FAILURES + 1))
fi
}
if [ ! -f "$RUNTIME/config.sh" ] || [ ! -f "$RUNTIME/health.sh" ]; then
echo "FAIL: runtime helpers are missing under $RUNTIME"
exit 1
fi
# shellcheck source=/dev/null
. "$RUNTIME/config.sh"
# shellcheck source=/dev/null
. "$RUNTIME/health.sh"
LIVE="$WORK/run/sing-box-c.json"
GOOD="$WORK/run/known-good/sing-box-c.json"
echo "== config transaction =="
# Nothing generated yet and no fallback: unusable.
hp_ensure_live "$LIVE" "$GOOD"
expect "ensure_live: nothing usable" "$?" "2"
# A configuration that passed check becomes the known-good copy.
mkdir -p "$WORK/run"
printf 'v1\n' > "$LIVE"
hp_known_good "$LIVE" "$GOOD"
expect "known_good: recorded" "$?" "0"
expect "known_good: content" "$(cat "$GOOD")" "v1"
# Live file present -> ensure_live leaves it alone.
hp_ensure_live "$LIVE" "$GOOD"
expect "ensure_live: keeps the live file" "$?" "0"
# Generation failed and removed the live file -> restore the fallback. This
# is the start-up path: without it a failed generation left the router with
# no configuration at all.
rm -f "$LIVE"
hp_ensure_live "$LIVE" "$GOOD"
expect "ensure_live: restores the fallback" "$?" "1"
expect "ensure_live: restored content" "$(cat "$LIVE")" "v1"
# A new configuration that passes check but is not yet proven: the fallback
# still holds the previous one.
printf 'v2\n' > "$LIVE"
hp_same_file "$LIVE" "$GOOD"
expect "same_file: different configs" "$?" "1"
hp_rollback "$LIVE" "$GOOD"
expect "rollback: applied" "$?" "0"
expect "rollback: previous content restored" "$(cat "$LIVE")" "v1"
hp_same_file "$LIVE" "$GOOD"
expect "same_file: identical configs" "$?" "0"
# Nothing to roll back to must be reported, not silently ignored.
rm -f "$GOOD"
hp_rollback "$LIVE" "$GOOD"
expect "rollback: refuses without a fallback" "$?" "1"
hp_same_file "$LIVE" "$GOOD"
expect "same_file: missing file is not equal" "$?" "1"
echo "== health probe =="
# Deterministic stubs so the probe's control flow is tested rather than the
# host's process table or a real service.
#
# The gate asks procd first (ubus + jsonfilter) and only falls back to a
# process scan when procd cannot be asked at all. That order is the fix for
# the P0: with the process scan
# first, a STALE sing-box process left over from an earlier run kept answering
# "alive" while procd restarted a crashing instance, so a dead service was
# accepted and recorded as the new known-good. The first case below pins
# exactly that.
BIN="$WORK/bin"
mkdir -p "$BIN"
# One sample == one ubus call, so the counter indexes the health pattern.
cat > "$BIN/ubus" <<'STUB'
#!/bin/sh
n=$(cat "$HP_FAKE_COUNTER" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" > "$HP_FAKE_COUNTER"
echo 'procd-state'
STUB
cat > "$BIN/pgrep" <<'STUB'
#!/bin/sh
[ "${HP_FAKE_STALE_PROC:-0}" = "1" ] && exit 0
exit 1
STUB
cat > "$BIN/jsonfilter" <<'STUB'
#!/bin/sh
# HP_FAKE_PATTERN is a string of 1/0, one character per sample.
n=$(cat "$HP_FAKE_COUNTER" 2>/dev/null || echo 1)
[ "$n" -ge 1 ] || n=1
ch=$(printf '%s' "${HP_FAKE_PATTERN:-1}" | cut -c "$n")
[ -n "$ch" ] || ch=$(printf '%s' "${HP_FAKE_PATTERN:-1}" | cut -c 1)
up=no
[ "$ch" = "1" ] && up=yes
case "$*" in
*".instances["*)
if [ "$up" = "yes" ]; then
printf '{"running":true}\n'
else
printf '{"running":false,"exit_code":1}\n'
fi
;;
*"@.running"*) [ "$up" = "yes" ] && echo true || echo false ;;
*"@.exit_code"*) [ "$up" = "yes" ] || echo 1 ;;
esac
exit 0
STUB
cat > "$BIN/netstat" <<'STUB'
#!/bin/sh
echo "Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name"
if [ "${HP_FAKE_NO_OWNER:-0}" = "1" ]; then
printf 'tcp 0 0 :::%s :::* LISTEN\n' "${HP_FAKE_PORT:-5330}"
elif [ -n "${HP_FAKE_PORT:-}" ]; then
printf 'tcp 0 0 :::%s :::* LISTEN 42/%s\n' "$HP_FAKE_PORT" "${HP_FAKE_PORT_OWNER:-sing-box}"
fi
exit 0
STUB
# The health budget is measured in samples, not wall-clock seconds; sleeping
# for real would make each case take the whole budget in seconds.
cat > "$BIN/sleep" <<'STUB'
#!/bin/sh
exit 0
STUB
chmod +x "$BIN/pgrep" "$BIN/ubus" "$BIN/jsonfilter" "$BIN/netstat" "$BIN/sleep"
PATH="$BIN:$PATH"
export PATH
CFG="$WORK/run/sing-box-c.json"
: > "$CFG"
COUNTER="$WORK/sample-count"
HP_FAKE_COUNTER="$COUNTER"
export HP_FAKE_COUNTER
reset_samples() {
echo 0 > "$COUNTER"
}
# 1. procd says down while a stale process still matches the scan.
HP_FAKE_PATTERN=0 HP_FAKE_STALE_PROC=1
export HP_FAKE_PATTERN HP_FAKE_STALE_PROC
reset_samples
hp_instance_running "sing-box-c" "$CFG"
expect "instance_running: a stale process does not override procd" "$?" "1"
# 2. procd says running and reports no failed exit code.
HP_FAKE_PATTERN=1 HP_FAKE_STALE_PROC=0
export HP_FAKE_PATTERN HP_FAKE_STALE_PROC
reset_samples
hp_instance_running "sing-box-c" "$CFG"
expect "instance_running: reports up" "$?" "0"
# 3. hp_wait_service needs the instance healthy for several CONSECUTIVE
# samples: one lucky poll must not be enough (that is what let a
# crash-restart loop pass the old gate).
HP_FAKE_PATTERN=101010
export HP_FAKE_PATTERN
reset_samples
hp_wait_service "sing-box-c" "$CFG" 6 3
expect "wait_service: alternating samples never reach the stability window" "$?" "1"
# 4. A bad sample early in the window resets the count, and a later healthy run
# still succeeds.
HP_FAKE_PATTERN=110111
export HP_FAKE_PATTERN
reset_samples
hp_wait_service "sing-box-c" "$CFG" 6 3
expect "wait_service: recovers after a bad sample" "$?" "0"
# 5. Listener attribution. "The port is in the listen table" is not enough: in
# the failure this gate exists for, the port was listening but owned by the
# process that had taken it.
HP_FAKE_PORT=5330 HP_FAKE_PORT_OWNER=sing-box
export HP_FAKE_PORT HP_FAKE_PORT_OWNER
hp_listener_owned "sing-box" 5330
expect "listener: owned by sing-box" "$?" "0"
HP_FAKE_PORT_OWNER=socat
export HP_FAKE_PORT_OWNER
hp_listener_owned "sing-box" 5330
expect "listener: listening but owned by another process fails" "$?" "1"
HP_FAKE_NO_OWNER=1
export HP_FAKE_NO_OWNER
hp_listener_owned "sing-box" 5330
expect "listener: no owner column is reported as unavailable" "$?" "2"
unset HP_FAKE_NO_OWNER
HP_FAKE_PORT=5330 HP_FAKE_PORT_OWNER=sing-box
export HP_FAKE_PORT HP_FAKE_PORT_OWNER
hp_listener_owned "sing-box" 5330 5399
expect "listener: a missing port fails" "$?" "1"
# 6. The whole sample: procd up and the listeners owned.
HP_FAKE_PATTERN=1
export HP_FAKE_PATTERN
reset_samples
hp_service_healthy "sing-box-c" "$CFG" 5330
expect "service_healthy: procd up and listeners owned" "$?" "0"
HP_FAKE_PORT_OWNER=socat
export HP_FAKE_PORT_OWNER
reset_samples
hp_service_healthy "sing-box-c" "$CFG" 5330
expect "service_healthy: procd up but the listener was taken by another process" "$?" "1"
# 7. hp_wait_instance is kept for compatibility.
HP_FAKE_PORT_OWNER=sing-box HP_FAKE_PATTERN=1
export HP_FAKE_PORT_OWNER HP_FAKE_PATTERN
reset_samples
hp_wait_instance "sing-box-c" "$CFG" 1
expect "wait_instance (compat): succeeds while running" "$?" "0"
HP_FAKE_PATTERN=0
export HP_FAKE_PATTERN
reset_samples
hp_wait_instance "sing-box-c" "$CFG" 1
expect "wait_instance (compat): times out when down" "$?" "1"
printf '%d checks, %d failures\n' "$CHECKS" "$FAILURES"
exit $FAILED
@@ -1,107 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# hp_crontab_drop() rewrites /etc/crontabs/root through a temporary file, and
# `mv` replaces the *file*, not its contents: the temporary carries the shell's
# umask (0644) while procd creates the root crontab as 0600. Every stop/start
# therefore left it world-readable - confirmed on the device (0600 after a
# boot, 0644 after one reload) - and a root crontab can hold other jobs'
# secrets.
#
# The function takes the crontab path as an argument and the module is
# device-side, so this drives it against a sandboxed file and never touches the
# host's own crontab.
#
# Usage: sh tests/runtime/test_crontab_perms.sh <repo-root> [workdir]
set -u
ROOT="${1:-.}"
WORK="${2:-$(mktemp -d)}"
FAILED=0
fail() {
echo "FAIL: $*"
FAILED=1
}
# BSD stat takes -f, GNU takes -c; try both rather than assuming a host.
file_mode() {
stat -c '%a' "$1" 2>"/dev/null" || stat -f '%Lp' "$1" 2>"/dev/null"
}
rm -rf "$WORK"
mkdir -p "$WORK"
# The module is sourced by the init script, which provides log() and CONF.
CONF="homeproxy"
log() { printf '%s\n' "$*" >> "$WORK/log"; }
# shellcheck disable=SC1091
. "$ROOT/root/etc/homeproxy/scripts/runtime/service.sh"
CRONTAB="$WORK/crontab"
write_crontab() {
cat > "$CRONTAB" <<-'EOF'
0 3 * * * root /usr/bin/keep-me
30 4 * * * root /etc/homeproxy/scripts/update_subscriptions.uc #homeproxy_autosetup
EOF
}
# --- the entry is dropped and the rest of the file survives ---------------
write_crontab
chmod 600 "$CRONTAB"
hp_crontab_drop "$CRONTAB"
drop_rc=$?
if [ "$drop_rc" = 0 ]; then
echo "PASS: hp_crontab_drop reports success"
else
fail "hp_crontab_drop returned $drop_rc"
fi
if grep -q "keep-me" "$CRONTAB" && ! grep -q "autosetup" "$CRONTAB"; then
echo "PASS: the auto-update entry is gone and other jobs survive"
else
fail "the crontab was not edited as expected:"
sed 's/^/ /' "$CRONTAB"
fi
if [ "$(file_mode "$CRONTAB")" = "600" ]; then
echo "PASS: the root crontab is left 0600"
else
fail "the root crontab is $(file_mode "$CRONTAB") after the rewrite; it was 0600"
fi
# --- a file an older version already loosened is repaired -----------------
write_crontab
chmod 644 "$CRONTAB"
hp_crontab_drop "$CRONTAB"
if [ "$(file_mode "$CRONTAB")" = "600" ]; then
echo "PASS: a world-readable crontab is tightened to 0600"
else
fail "a crontab that was already 0644 stayed $(file_mode "$CRONTAB")"
fi
# --- a missing crontab is reported, not invented --------------------------
rm -f "$CRONTAB"
if hp_crontab_drop "$CRONTAB"; then
fail "hp_crontab_drop claimed success for a crontab that does not exist"
else
echo "PASS: a missing crontab is reported as a failure"
fi
if [ -e "$CRONTAB" ]; then
fail "hp_crontab_drop created a crontab that did not exist"
else
echo "PASS: no crontab is created out of nothing"
fi
rm -rf "$WORK"
if [ "$FAILED" != 0 ]; then
exit 1
fi
exit 0
@@ -1,253 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# dnsmasq snippet-writer tests (review L6).
#
# hp_dnsmasq_write_snippets used to rewrite the snippet set and restart
# dnsmasq unconditionally. A restart drops every client's DNS cache, and the
# list files it feeds on change once a day at most while the routing mode and
# the DNS port stay put - so an ordinary `reload` flushed the whole LAN's DNS
# cache for no reason.
#
# The writer now renders into a staging directory and compares with what is
# installed; the restart happens only when something actually changed. These
# tests pin that contract from the outside, by counting restarts through a
# stub:
#
# * an identical second call does not restart
# * a changed resource list does restart
# * a changed routing mode does restart, and drops the snippet the old
# mode produced (a stale gfw_list.conf next to redirect-dns.conf would
# have dnsmasq serve both)
# * toggling ipv6_support does restart (the nftset suffix changes)
# * removing the resource list does restart, and removes the snippet, so
# the next call is stable again
#
# The stub is a stand-in for /etc/init.d/dnsmasq, wired in the same way
# tests/runtime/test_runtime_extraction.sh wires its init scripts: the copy of
# dns.sh under test is sed-rewritten so the absolute path points into the
# sandbox.
#
# Usage: sh tests/runtime/test_dns_snippets.sh <repo-root> [work-dir]
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
RUNTIME="$ROOT/root/etc/homeproxy/scripts/runtime"
WORK="${2:-$(mktemp -d "${TMPDIR:-/tmp}/hp-dns-snippets.XXXXXX")}"
OWN_WORK=0
[ -n "${2:-}" ] || OWN_WORK=1
[ "$OWN_WORK" -eq 1 ] && trap 'rm -rf "$WORK"' EXIT INT TERM
FAILED=0
CHECKS=0
FAILURES=0
expect() {
# expect <name> <actual> <expected>
CHECKS=$((CHECKS + 1))
if [ "$2" = "$3" ]; then
echo "PASS: $1"
else
echo "FAIL: $1 (expected '$3', got '$2')"
FAILED=1
FAILURES=$((FAILURES + 1))
fi
}
exists() {
# exists <path> -> yes|no, so an assertion can compare it as a value.
[ -e "$1" ] && echo yes || echo no
}
has_line() {
# has_line <pattern> <file> -> yes|no. A count would be brittle here:
# each domain in a gfw/proxy snippet expands to a server= line *and* an
# nftset= line, so the interesting question is presence, not multiplicity.
grep -q -- "$1" "$2" 2>"/dev/null" && echo yes || echo no
}
if [ ! -f "$RUNTIME/dns.sh" ]; then
echo "FAIL: $RUNTIME/dns.sh is missing"
exit 1
fi
rm -rf "$WORK"
mkdir -p "$WORK/init.d" \
"$WORK/hp/resources" \
"$WORK/dnsmasq.d"
DNS_DIR="$WORK/dnsmasq.d/dnsmasq-homeproxy.d"
INCLUDE="$WORK/dnsmasq.d/dnsmasq-homeproxy.conf"
RESTARTS="$WORK/restarts"
LOG="$WORK/log"
# --- stubs --------------------------------------------------------------
# runtime/dns.sh probes `dnsmasq --version` for nftset support before emitting
# nftset= directives. This suite stubs the init script, not the binary, so
# state the answer instead: nftset snippets are what a full dnsmasq emits, and
# that is what the cases below are about.
HP_DNSMASQ_NFTSET=1
export HP_DNSMASQ_NFTSET
# Stand-in for /etc/init.d/dnsmasq: counts restarts instead of running one.
cat > "$WORK/init.d/dnsmasq" <<EOF
#!/bin/sh
echo "\$*" >> "$RESTARTS"
exit 0
EOF
chmod +x "$WORK/init.d/dnsmasq"
# dnsmasq.sh is sourced, so the two ini.d paths inside it have to point at
# the stub. Only the absolute path is rewritten - the same trick the runtime
# extraction test uses.
sed "s#/etc/init.d/dnsmasq#$WORK/init.d/dnsmasq#g" \
"$RUNTIME/dns.sh" > "$WORK/dns.sh"
if ! grep -qF "$WORK/init.d/dnsmasq" "$WORK/dns.sh"; then
echo "FAIL: could not rewrite the dnsmasq init path in the dns.sh copy"
exit 1
fi
# The module calls log() and the two config readers; a device provides them
# via /lib/functions.sh and the init script. Both are stubbed to read from
# this test's environment so a scenario is one variable assignment.
log() { echo "$*" >> "$LOG"; }
config_get_bool() { eval "$1=\"\${HP_IPV6:-0}\""; }
config_get() { eval "$1=\"\${HP_DNS_PORT:-5333}\""; }
# shellcheck source=/dev/null
. "$WORK/dns.sh"
restart_count() { wc -l < "$RESTARTS" 2>/dev/null | tr -d ' '; }
# --- fixtures -----------------------------------------------------------
printf 'example.com\nfoo.example.org\n' > "$WORK/hp/resources/gfw_list.txt"
printf 'example.cn\n' > "$WORK/hp/resources/china_list.txt"
printf 'proxy.example.net\n' > "$WORK/hp/resources/proxy_list.txt"
run() {
# run <routing-mode>
hp_dnsmasq_write_snippets "$DNS_DIR" "$WORK/hp" "$1"
}
: > "$RESTARTS"
: > "$LOG"
echo "== gfwlist: first write, then an identical re-run =="
run "gfwlist"
expect "first write restarts dnsmasq" "$(restart_count)" "1"
expect "gfw_list.conf installed" "$(exists "$DNS_DIR/gfw_list.conf")" "yes"
expect "proxy_list.conf installed" "$(exists "$DNS_DIR/proxy_list.conf")" "yes"
expect "include file points at the dir" "$(cat "$INCLUDE")" "conf-dir=$DNS_DIR"
# The whole point of L6: same inputs -> no restart.
run "gfwlist"
expect "identical re-run does not restart" "$(restart_count)" "1"
expect "identical re-run logs the skip" \
"$(grep -c 'unchanged' "$LOG")" "1"
echo "== gfwlist: a changed resource list restarts =="
printf 'example.com\nfoo.example.org\nnew.example.net\n' > "$WORK/hp/resources/gfw_list.txt"
run "gfwlist"
expect "changed gfw_list.txt restarts dnsmasq" "$(restart_count)" "2"
expect "the new domain reached the snippet" \
"$(has_line 'new.example.net' "$DNS_DIR/gfw_list.conf")" "yes"
# ...and is stable again straight after.
run "gfwlist"
expect "re-run after the change does not restart" "$(restart_count)" "2"
echo "== toggling ipv6_support restarts (the nftset suffix changes) =="
HP_IPV6=1 run "gfwlist"
expect "ipv6_support on restarts dnsmasq" "$(restart_count)" "3"
expect "the v6 nftset suffix reached the snippet" \
"$(has_line 'homeproxy_gfw_list_v6' "$DNS_DIR/gfw_list.conf")" "yes"
HP_IPV6=1 run "gfwlist"
expect "re-run with ipv6 on does not restart" "$(restart_count)" "3"
HP_IPV6=0 run "gfwlist"
expect "ipv6_support off restarts again" "$(restart_count)" "4"
echo "== a changed routing mode restarts and drops the stale snippet =="
run "bypass_mainland_china"
expect "mode change restarts dnsmasq" "$(restart_count)" "5"
expect "redirect-dns.conf installed" "$(exists "$DNS_DIR/redirect-dns.conf")" "yes"
expect "the old gfw_list.conf is gone" "$(exists "$DNS_DIR/gfw_list.conf")" "no"
expect "redirect-dns.conf names the DNS port" \
"$(has_line 'server=127.0.0.1#5333' "$DNS_DIR/redirect-dns.conf")" "yes"
run "bypass_mainland_china"
expect "same mode re-run does not restart" "$(restart_count)" "5"
echo "== a removed resource list restarts and removes its snippet =="
rm -f "$WORK/hp/resources/proxy_list.txt"
run "bypass_mainland_china"
expect "removing proxy_list.txt restarts" "$(restart_count)" "6"
expect "the stale proxy_list.conf is gone" "$(exists "$DNS_DIR/proxy_list.conf")" "no"
run "bypass_mainland_china"
expect "re-run after the removal does not restart" "$(restart_count)" "6"
echo "== removal leaves nothing to restart for =="
hp_dnsmasq_remove_snippets "$DNS_DIR"
expect "remove restarts dnsmasq once" "$(restart_count)" "7"
expect "the snippet dir is gone" "$(exists "$DNS_DIR")" "no"
expect "the include file is gone" "$(exists "$INCLUDE")" "no"
hp_dnsmasq_remove_snippets "$DNS_DIR"
expect "a second remove does not restart" "$(restart_count)" "7"
echo "== an unusable infra.dns_port cannot reach the snippet =="
# `infra` has no UI and no validator, and the value is interpolated into sed's
# replacement. '5333\nlog-queries' is accepted by the generator - int() is
# strtoll-based, so it reads 5333 - and sed turns the `\n` into a real newline,
# which used to append a dnsmasq directive of the writer's choosing. A value
# with a `/` made sed fail instead, and the `>`-truncated file was installed as
# a valid (empty) snippet. Both have to be neutralised, and a value that is
# not a port at all has to leave the installed set alone.
run "bypass_mainland_china"
expect "the good snippet set is installed" "$(exists "$DNS_DIR/redirect-dns.conf")" "yes"
GOOD_CONF="$(cat "$DNS_DIR/redirect-dns.conf")"
GOOD_RESTARTS="$(restart_count)"
HP_DNS_PORT="$(printf '5333\nlog-queries')" run "bypass_mainland_china"
expect "a newline in dns_port cannot inject a directive" \
"$(has_line 'log-queries' "$DNS_DIR/redirect-dns.conf")" "no"
expect "a newline in dns_port keeps the installed snippet" \
"$(cat "$DNS_DIR/redirect-dns.conf")" "$GOOD_CONF"
expect "a newline in dns_port does not restart dnsmasq" "$(restart_count)" "$GOOD_RESTARTS"
HP_DNS_PORT='5333/evil' run "bypass_mainland_china"
expect "a slash in dns_port cannot break the snippet" \
"$(has_line 'evil' "$DNS_DIR/redirect-dns.conf")" "no"
expect "a slash in dns_port keeps the installed snippet" \
"$(cat "$DNS_DIR/redirect-dns.conf")" "$GOOD_CONF"
expect "a slash in dns_port does not restart dnsmasq" "$(restart_count)" "$GOOD_RESTARTS"
HP_DNS_PORT='not-a-port' run "bypass_mainland_china"
expect "an unusable dns_port leaves the snippet in place" \
"$(cat "$DNS_DIR/redirect-dns.conf")" "$GOOD_CONF"
expect "an unusable dns_port does not restart dnsmasq" "$(restart_count)" "$GOOD_RESTARTS"
expect "an unusable dns_port is reported in the log" \
"$(grep -c 'dns_port' "$LOG")" "1"
echo
printf '%s checks, %s failures\n' "$CHECKS" "$FAILURES"
if [ "$FAILED" != 0 ]; then
echo "DNS SNIPPET TESTS FAILED"
exit 1
fi
echo "DNS SNIPPET TESTS PASSED"
exit 0
@@ -1,221 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Functional health probes (runtime/health.sh).
#
# The process/listener gate answers "is the instance up and does it own its
# ports?". It cannot answer "does it actually serve?", and the probes that do
# are on the same code path a rollback is decided on - so the one property that
# must not regress is that they only *report* by default. A probe that fails
# because the uplink is down, or because this target has no nslookup/nc, must
# never be able to fail the gate: a rollback loop is worse than the condition
# being probed for.
#
# `health_probe_strict` (UCI) is the opt-in that turns a probe failure into a
# gate failure. "Cannot probe here" stays non-fatal even then - there is
# nothing to conclude from a missing tool.
#
# The probes take explicit ports and shell out to nslookup/nc, so this runs
# anywhere without ucode, procd or a router.
#
# Usage: sh tests/runtime/test_health_probe.sh <repo-root>
ROOT="${1:-.}"
ROOT="$(cd "$ROOT" && pwd)"
RUNTIME="$ROOT/root/etc/homeproxy/scripts/runtime"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/hp-probes.XXXXXX")" || exit 1
trap 'rm -rf "$WORK"' EXIT INT TERM
FAILED=0
CHECKS=0
FAILURES=0
expect() {
# expect <name> <actual> <expected>
CHECKS=$((CHECKS + 1))
if [ "$2" = "$3" ]; then
echo "PASS: $1"
else
echo "FAIL: $1 (expected '$3', got '$2')"
FAILED=1
FAILURES=$((FAILURES + 1))
fi
}
if [ ! -f "$RUNTIME/health.sh" ]; then
echo "FAIL: runtime/health.sh is missing under $RUNTIME"
exit 1
fi
# log() comes from init.d on a router; the probes only call it to report, so
# the test records the lines and asserts on them.
PROBE_LOG="$WORK/probe.log"
: > "$PROBE_LOG"
log() { printf '%s\n' "$*" >> "$PROBE_LOG"; }
# shellcheck source=/dev/null
. "$RUNTIME/health.sh"
# --- stub PATH -------------------------------------------------------------
# The probes are written against `command -v nslookup` / `command -v nc`, so
# the sandbox decides what exists. Each stub's behaviour follows a marker file,
# which keeps the call sites readable.
BIN_OK="$WORK/bin-ok"
BIN_NONE="$WORK/bin-none"
mkdir -p "$BIN_OK" "$BIN_NONE"
cat > "$BIN_OK/nslookup" <<'EOF'
#!/bin/sh
[ -f "$HP_PROBE_STUB_DIR/dns_ok" ] || exit 1
exit 0
EOF
cat > "$BIN_OK/nc" <<'EOF'
#!/bin/sh
[ -f "$HP_PROBE_STUB_DIR/tcp_ok" ] || exit 1
exit 0
EOF
chmod +x "$BIN_OK/nslookup" "$BIN_OK/nc"
HP_PROBE_STUB_DIR="$WORK"
export HP_PROBE_STUB_DIR
PATH="$BIN_OK:$PATH"
export PATH
echo "== hp_probe_dns =="
: > "$WORK/dns_ok"
hp_probe_dns 5333 example.com
expect "dns: an answering inbound is 0" "$?" "0"
rm -f "$WORK/dns_ok"
hp_probe_dns 5333 example.com
expect "dns: a silent inbound is 1" "$?" "1"
hp_probe_dns "" example.com
expect "dns: no port means cannot-probe (2), not failure" "$?" "2"
echo "== hp_probe_tcp =="
: > "$WORK/tcp_ok"
hp_probe_tcp 5330
expect "tcp: an accepting listener is 0" "$?" "0"
rm -f "$WORK/tcp_ok"
hp_probe_tcp 5330
expect "tcp: a refusing listener is 1" "$?" "1"
hp_probe_tcp ""
expect "tcp: no port means cannot-probe (2), not failure" "$?" "2"
echo "== missing tooling is 'cannot probe', not 'failed' =="
# PATH is replaced, not prepended to: the host this runs on may well have its
# own nslookup/nc (macOS ships both), and a prepended empty directory would
# still find them behind `command -v`.
SAVED_PATH="$PATH"
PATH="$BIN_NONE"
hp_probe_dns 5333 example.com
rc_dns_missing=$?
hp_probe_tcp 5330
rc_tcp_missing=$?
PATH="$SAVED_PATH"
expect "dns: no nslookup is 2" "$rc_dns_missing" "2"
expect "tcp: no nc is 2" "$rc_tcp_missing" "2"
export PATH
echo "== hp_run_probes: reporting is the default =="
: > "$PROBE_LOG"
rm -f "$WORK/dns_ok" "$WORK/tcp_ok"
HP_PROBE_STRICT=0
export HP_PROBE_STRICT
hp_run_probes "client" 5333 5330
expect "reporting: both probes failing still returns 0" "$?" "0"
expect "reporting: the DNS failure is logged" \
"$(grep -c 'the client DNS inbound (127.0.0.1:5333) did not answer' "$PROBE_LOG")" "1"
expect "reporting: the TCP failure is logged" \
"$(grep -c 'nothing accepted a connection on the client mixed inbound (127.0.0.1:5330)' "$PROBE_LOG")" "1"
echo "== hp_run_probes: strict turns a failure into a gate failure =="
: > "$PROBE_LOG"
HP_PROBE_STRICT=1
export HP_PROBE_STRICT
hp_run_probes "client" 5333 5330
expect "strict: a failed probe returns 1" "$?" "1"
expect "strict: the strict path is named in the log" \
"$(grep -c 'health_probe_strict is on' "$PROBE_LOG")" "1"
echo "== hp_run_probes: strict does not fire on a healthy service =="
: > "$WORK/dns_ok"
: > "$WORK/tcp_ok"
: > "$PROBE_LOG"
hp_run_probes "client" 5333 5330
expect "strict: a passing probe returns 0" "$?" "0"
expect "strict: a passing probe logs nothing" "$(wc -l < "$PROBE_LOG" | tr -d ' ')" "0"
echo "== hp_run_probes: 'cannot probe' never fails the gate =="
rm -f "$WORK/dns_ok" "$WORK/tcp_ok"
: > "$PROBE_LOG"
HP_PROBE_STRICT=1
export HP_PROBE_STRICT
PATH="$BIN_NONE"
hp_run_probes "client" 5333 5330
rc_no_tools=$?
PATH="$SAVED_PATH"
export PATH
expect "strict: a probe that cannot run still returns 0" "$rc_no_tools" "0"
expect "strict: the missing tooling is reported" \
"$(grep -c 'cannot probe the client' "$PROBE_LOG")" "2"
echo "== hp_config_port_for_tag =="
# The generator writes one field per line (%.J); the sed in the helper also has
# to accept the compact shape, because a config that came from anywhere else
# (or a fixture) must not silently yield "no port" - that is how the probes
# would end up probing nothing at all.
cat > "$WORK/pretty.json" <<'EOF'
{
"inbounds": [
{
"tag": "dns-in",
"listen": "127.0.0.1",
"listen_port": 5333
},
{
"tag": "mixed-in",
"listen": "::",
"listen_port": 5330
},
{
"tag": "tproxy-in",
"listen_port": 5332
}
],
"outbounds": [
{
"tag": "direct-out",
"type": "direct"
}
]
}
EOF
cat > "$WORK/compact.json" <<'EOF'
{"inbounds":[{"tag":"dns-in","listen_port":5333},{"tag":"mixed-in","listen_port":5330}],"outbounds":[{"tag":"direct-out","type":"direct"}]}
EOF
expect "port_for_tag: dns-in from a pretty config" \
"$(hp_config_port_for_tag "$WORK/pretty.json" 'dns-in')" "5333"
expect "port_for_tag: mixed-in from a pretty config" \
"$(hp_config_port_for_tag "$WORK/pretty.json" 'mixed-in')" "5330"
expect "port_for_tag: a third inbound is not confused with the first" \
"$(hp_config_port_for_tag "$WORK/pretty.json" 'tproxy-in')" "5332"
expect "port_for_tag: an outbound tag is not read as a listener" \
"$(hp_config_port_for_tag "$WORK/pretty.json" 'direct-out')" ""
expect "port_for_tag: an absent tag yields nothing" \
"$(hp_config_port_for_tag "$WORK/pretty.json" 'nope-in')" ""
expect "port_for_tag: compact JSON yields the same port" \
"$(hp_config_port_for_tag "$WORK/compact.json" 'dns-in')" "5333"
expect "config_ports: both listeners are reported" \
"$(hp_config_ports "$WORK/pretty.json" | tr '\n' ' ' | sed 's/ $//')" "5333 5330 5332"
printf '%d checks, %d failures\n' "$CHECKS" "$FAILURES"
[ "$FAILED" = 0 ] && echo "HEALTH PROBE TESTS PASSED"
exit "$FAILED"
@@ -1,255 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# update_resources.sh: the content-verification path.
#
# The download URL is pinned to a commit, and the script now refuses to install
# a file whose git blob id does not match the one GitHub reports for that path
# and commit. The whole point of the check is that it *refuses*, so a driver
# that only walks the happy path would keep passing with the check deleted -
# this one drives the four outcomes and asserts what each leaves behind:
#
# 1. digest matches -> installed, .ver advanced
# 2. digest differs -> NOT installed, previous file and .ver intact
# 3. no digest from the API-> NOT installed (fail closed)
# 4. no local digest -> NOT installed (fail closed)
# plus: already up to date -> no download at all
#
# Everything external is stubbed (wget, jsonfilter, ucode, uci, flock) and the
# script's absolute paths are rewritten into a sandbox, so this is pure shell
# and runs on a laptop, in CI and on a target. The ucode stub answers with a
# fixed digest: what the *shipped* helper computes is
# tests/ucode/test_resource_blob_sha.sh's job, which checks it against git.
#
# Usage: sh tests/runtime/test_resource_update.sh <repo-root> [work-dir]
set -u
ROOT="$(cd "${1:-.}" && pwd)"
WORK="${2:-/tmp/hp-resource-update-test}"
rm -rf "$WORK"
mkdir -p "$WORK/scripts" "$WORK/resources" "$WORK/run" "$WORK/bin"
FAILED=0
CHECKS=0
FAILURES=0
expect() {
# expect <name> <actual> <expected>
CHECKS=$((CHECKS + 1))
if [ "$2" = "$3" ]; then
echo "PASS: $1"
else
echo "FAIL: $1 (expected '$3', got '$2')"
FAILED=1
FAILURES=$((FAILURES + 1))
fi
}
# --- stage the script and its helper ----------------------------------------
cp "$ROOT/root/etc/homeproxy/scripts/update_resources.sh" "$WORK/scripts/"
cp "$ROOT/root/etc/homeproxy/scripts/resource_blob_sha.uc" "$WORK/scripts/"
# Rewrite the two absolute runtime paths. The anchors are asserted below: a sed
# that quietly stopped matching would leave the test writing to /etc.
sed -e "s|^RESOURCES_DIR=\"/etc/\$NAME/resources\"|RESOURCES_DIR=\"$WORK/resources\"|" \
-e "s|^RUN_DIR=\"/var/run/\$NAME\"|RUN_DIR=\"$WORK/run\"|" \
"$WORK/scripts/update_resources.sh" > "$WORK/scripts/update_resources.sh.new"
mv -f "$WORK/scripts/update_resources.sh.new" "$WORK/scripts/update_resources.sh"
for anchor in "RESOURCES_DIR=\"$WORK/resources\"" "RUN_DIR=\"$WORK/run\""; do
grep -qF "$anchor" "$WORK/scripts/update_resources.sh" || {
echo "FAIL: could not sandbox update_resources.sh - missing anchor: $anchor"
exit 1
}
done
# --- stubs ------------------------------------------------------------------
# wget serves three shapes, chosen by the URL: the commit list, the contents
# metadata, and the file itself. What the contents API reports and what the
# file contains are control files, so each case can make them agree or not.
cat > "$WORK/bin/wget" <<'EOF'
#!/bin/sh
url=""
out=""
while [ $# -gt 0 ]; do
case "$1" in
-O) out="$2"; shift 2 ;;
-O-|--spider) shift ;;
--timeout=*|--header) shift 2 ;;
*) url="$1"; shift ;;
esac
done
emit() { [ -n "$out" ] && printf '%s' "$1" > "$out" || printf '%s' "$1"; }
case "$url" in
*api.github.com/*/commits*) emit "$(cat "$HP_T_API_COMMITS")" ;;
*api.github.com/*/contents*) emit "$(cat "$HP_T_API_CONTENTS")" ;;
*)
if [ -n "${HP_T_WGET_FAIL:-}" ]; then exit 1; fi
emit "$(cat "$HP_T_BODY")"
;;
esac
exit 0
EOF
# jsonfilter: answer the two expressions the script actually asks for. A stub
# that simply returned "the first sha" also answered the commit-date query with
# the commit sha, which silently made the expected .ver string wrong - the real
# jsonfilter reads the expression it is handed, so this one does too.
cat > "$WORK/bin/jsonfilter" <<'EOF'
#!/bin/sh
expr=""
for a in "$@"; do expr="$a"; done
payload="$(cat)"
case "$expr" in
*commit.committer.date*)
printf '%s' "$payload" | sed -n 's/.*"date"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1
;;
*)
printf '%s' "$payload" | sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1
;;
esac
EOF
# ucode: the shipped helper's contract, answered from a control file. An empty
# control file means "cannot compute" (the helper exits 1 and prints nothing).
cat > "$WORK/bin/ucode" <<'EOF'
#!/bin/sh
[ -s "$HP_T_LOCAL_BLOB" ] || exit 1
cat "$HP_T_LOCAL_BLOB"
EOF
# uci/flock are only reached for the token and the lock; both are no-ops here.
cat > "$WORK/bin/uci" <<'EOF'
#!/bin/sh
exit 1
EOF
cat > "$WORK/bin/flock" <<'EOF'
#!/bin/sh
exit 0
EOF
chmod +x "$WORK/bin/"*
PATH="$WORK/bin:$PATH"
export PATH
# The script's shebang is /bin/sh, so run it under the strictest /bin/sh this
# host has: dash rejects the multi-digit file descriptor and the `&>` that
# busybox ash and bash accept greedily, and "exec: 200: not found" was how that
# reached CI. On a target there is no dash and busybox ash is the real thing.
RUN_SH="sh"
if command -v dash > "/dev/null" 2>&1; then
RUN_SH="dash"
fi
echo "running update_resources.sh under: $RUN_SH"
# --- fixtures ---------------------------------------------------------------
API_SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
LOCAL_SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
OUTPUT_SHA="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
cat > "$WORK/api-commits.json" <<EOF
[{"sha":"$API_SHA","commit":{"committer":{"date":"2026-01-02T03:04:05Z"}}}]
EOF
write_contents_json() {
# write_contents_json <blob-sha-or-empty>
if [ -n "$1" ]; then
printf '{"name":"ipv4.txt","size":6,"sha":"%s"}' "$1" > "$WORK/api-contents.json"
else
printf '{"name":"ipv4.txt","size":6}' > "$WORK/api-contents.json"
fi
}
printf 'fresh\n' > "$WORK/body.txt"
printf 'stale-installed-copy\n' > "$WORK/resources/china_ip4.txt"
printf 'OLDVERSION' > "$WORK/resources/china_ip4.ver"
export HP_T_API_COMMITS="$WORK/api-commits.json"
export HP_T_API_CONTENTS="$WORK/api-contents.json"
export HP_T_BODY="$WORK/body.txt"
run_case() {
# run_case <local-blob-file-content> <contents-api-sha>
printf '%s' "$1" > "$WORK/local-blob"
write_contents_json "$2"
export HP_T_LOCAL_BLOB="$WORK/local-blob"
"$RUN_SH" "$WORK/scripts/update_resources.sh" china_ip4 > "$WORK/stdout" 2>&1
echo $?
}
reset_state() {
printf 'stale-installed-copy\n' > "$WORK/resources/china_ip4.txt"
printf 'OLDVERSION' > "$WORK/resources/china_ip4.ver"
rm -f "$WORK/run/homeproxy.log" "$WORK/resources/china_ip4.updated_at"
}
echo "== case 1: the digest matches -> installed =="
reset_state
rc="$(run_case "$LOCAL_SHA" "$LOCAL_SHA")"
expect "exit status 0" "$rc" "0"
expect "the new list was installed" "$(cat "$WORK/resources/china_ip4.txt")" "fresh"
expect "the .ver was advanced to the resolved commit" \
"$(cat "$WORK/resources/china_ip4.ver")" "2026-01-02 $API_SHA"
expect "the success is logged" \
"$(grep -c 'Successfully updated via' "$WORK/run/homeproxy.log")" "1"
echo "== case 2: the digest differs -> refused, previous state kept =="
reset_state
rc="$(run_case "$OUTPUT_SHA" "$LOCAL_SHA")"
expect "exit status non-zero" "$rc" "1"
expect "the installed list is untouched" \
"$(cat "$WORK/resources/china_ip4.txt")" "stale-installed-copy"
expect "the .ver is untouched" "$(cat "$WORK/resources/china_ip4.ver")" "OLDVERSION"
expect "no .updated_at was written" "$([ -e "$WORK/resources/china_ip4.updated_at" ] && echo yes || echo no)" "no"
expect "the refusal is logged with both ids" \
"$(grep -c "does not match the content of commit $API_SHA" "$WORK/run/homeproxy.log")" "1"
expect "the downloaded copy was discarded" "$([ -e "$WORK/run/ipv4.txt" ] && echo yes || echo no)" "no"
echo "== case 3: no blob id from the API -> refused =="
reset_state
rc="$(run_case "$LOCAL_SHA" "")"
expect "exit status non-zero" "$rc" "1"
expect "the installed list is untouched" \
"$(cat "$WORK/resources/china_ip4.txt")" "stale-installed-copy"
expect "the .ver is untouched" "$(cat "$WORK/resources/china_ip4.ver")" "OLDVERSION"
expect "the reason is logged" \
"$(grep -c 'GitHub reports no blob id' "$WORK/run/homeproxy.log")" "1"
# Not merely "it refused": with the API check skipped, the comparison still
# refuses (an empty API id never equals a real one), so without this the check
# could be deleted and the case would still pass on the wrong reason.
expect "the API-missing path is the one taken" \
"$(grep -c 'does not match the content of commit' "$WORK/run/homeproxy.log")" "0"
echo "== case 4: no local digest -> refused =="
reset_state
rc="$(run_case "" "$LOCAL_SHA")"
expect "exit status non-zero" "$rc" "1"
expect "the installed list is untouched" \
"$(cat "$WORK/resources/china_ip4.txt")" "stale-installed-copy"
expect "the reason is logged" \
"$(grep -c 'cannot compute the blob id' "$WORK/run/homeproxy.log")" "1"
expect "the local-digest path is the one taken" \
"$(grep -c 'does not match the content of commit' "$WORK/run/homeproxy.log")" "0"
echo "== case 5: already at the resolved commit -> nothing downloaded =="
reset_state
printf '2026-01-02 %s' "$API_SHA" > "$WORK/resources/china_ip4.ver"
: > "$WORK/run/homeproxy.log"
rc="$(run_case "$LOCAL_SHA" "$LOCAL_SHA")"
expect "exit status 3 (up to date)" "$rc" "3"
expect "the installed list is untouched" \
"$(cat "$WORK/resources/china_ip4.txt")" "stale-installed-copy"
expect "it says so in the log" \
"$(grep -c 'already at the latest version' "$WORK/run/homeproxy.log")" "1"
printf '%d checks, %d failures\n' "$CHECKS" "$FAILURES"
if [ "$FAILED" != "0" ]; then
echo "RESOURCE UPDATE TESTS FAILED"
echo "--- last run output ---"
cat "$WORK/stdout"
exit 1
fi
echo "RESOURCE UPDATE TESTS PASSED"
exit 0
@@ -1,882 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Runtime orchestration trace test.
#
# Drives root/etc/init.d/homeproxy through a stubbed environment and records
# exactly what the orchestration does: which commands run, in what order, with
# which arguments, and which files end up where. The trace is compared against
# tests/fixtures/runtime/trace.golden.txt.
#
# Two baselines live in tests/fixtures/runtime/:
#
# trace.pre-pr05.txt captured from the 517-line init script before PHASE 7
# moved the dnsmasq/fw4/net/service plumbing out of it.
# It is the record that the extraction itself was
# behaviour-preserving (360 identical lines across three
# scenarios, commit c2aeac5).
# trace.golden.txt the baseline after the health-gate fix. The
# difference between the two files IS the intentional
# change: start_service now waits for hp_wait_service
# before logging "started", records known-good only
# after that gate passed, and reload_service rolls back
# through start instead of duplicating the gate.
# `diff trace.pre-pr05.txt trace.golden.txt` is the
# review artifact.
#
# Scenario D is the regression test for the P0: a candidate configuration whose
# mixed_port is already taken must NOT be recorded as known-good, and a reload
# must roll back to the previous configuration.
#
# Usage: sh tests/runtime/test_runtime_extraction.sh <repo-root> [work-dir]
#
# HP_INITD=<path> drive another init script
# HP_GOLDEN=<path> compare against another baseline
# HP_UPDATE_GOLDEN=1 rewrite the baseline instead of comparing
ROOT="${1:-.}"
# Per-run by default rather than a fixed /tmp path. This script has two
# callers (tests/run.sh and tests/ucode/run.sh) and one of them passed no work
# dir, so two concurrent suite runs shared /tmp/hp-runtime-trace and deleted
# each other's sandbox mid-test - the reproducible symptom was "could not
# sandbox ... missing anchor". tests/runtime/test_config_transaction.sh already
# defaults to mktemp -d.
WORK="${2:-$(mktemp -d "${TMPDIR:-/tmp}/hp-runtime-trace.XXXXXX")}"
OWN_WORK=0
[ -n "${2:-}" ] || OWN_WORK=1
# The dnsmasq conf path is the one shared resource left: it must stay at
# /tmp/etc/dnsmasq.conf.hp_test because the payload is what derives the section
# name, so it cannot move into $WORK. Concurrent runs therefore take turns on
# it, via mkdir (atomic everywhere, no flock dependency), with a bounded wait.
#
# The lock records the owner PID and its start time. A fixed mkdir lock with
# no owner information left the directory behind whenever a run was SIGKILLed,
# and the next run then burned the full two-minute timeout and failed - a
# "flaky" test whose real cause was a stale artifact. Now a lock whose holder
# is gone, or that is older than the stale threshold, is reclaimed instead of
# waited out. The pid is written immediately after mkdir; a crash in that
# microsecond-wide window is covered by the short no-pid grace below.
DNSMASQ_LOCK="/tmp/etc/.hp-runtime-trace.lock"
LOCK_TIMEOUT=120
# Reclaim a lock held longer than this before the full timeout: a live but
# wedged holder should not cost the next run two minutes. Well above the
# few seconds this test needs, so a healthy run is never stolen.
LOCK_STALE=90
mkdir -p "$(dirname "$DNSMASQ_LOCK")"
_lock_tries=0
while ! mkdir "$DNSMASQ_LOCK" 2>/dev/null; do
owner="$(cat "$DNSMASQ_LOCK/pid" 2>/dev/null || true)"
started="$(cat "$DNSMASQ_LOCK/started" 2>/dev/null || true)"
now="$(date +%s)"
case "$owner" in ''|*[!0-9]*) owner="" ;; esac
case "$started" in ''|*[!0-9]*) started="" ;; esac
if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then
echo "WARN: reclaiming stale lock $DNSMASQ_LOCK (holder pid $owner is gone)" >&2
rm -rf "$DNSMASQ_LOCK"
continue
fi
if [ -n "$started" ] && [ "$((now - started))" -gt "$LOCK_STALE" ]; then
echo "WARN: reclaiming lock $DNSMASQ_LOCK (held for $((now - started))s)" >&2
rm -rf "$DNSMASQ_LOCK"
continue
fi
if [ -z "$owner" ] && [ "$_lock_tries" -ge 5 ]; then
echo "WARN: reclaiming lock $DNSMASQ_LOCK (no owner pid was ever written)" >&2
rm -rf "$DNSMASQ_LOCK"
continue
fi
_lock_tries=$((_lock_tries + 1))
if [ "$_lock_tries" -gt "$LOCK_TIMEOUT" ]; then
echo "FAIL: another run has held $DNSMASQ_LOCK for over $LOCK_TIMEOUT seconds"
exit 1
fi
sleep 1
done
printf '%s\n' "$$" > "$DNSMASQ_LOCK/pid"
date +%s > "$DNSMASQ_LOCK/started"
# A trap rather than a line at the end: this script exits early on several
# failure paths, and the first version of this cleanup was inserted into the
# middle of one of them. Release only if this run still owns the lock (the
# pid/started files make that check possible), so a reclaimed lock is never
# removed out from under its new owner.
cleanup() {
if [ "$(cat "$DNSMASQ_LOCK/pid" 2>/dev/null || true)" = "$$" ]; then
rm -rf "$DNSMASQ_LOCK"
fi
[ "$OWN_WORK" = 1 ] && rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
ROOT="$(cd "$ROOT" && pwd)"
SCRIPTS="$ROOT/root/etc/homeproxy/scripts"
GOLDEN="${HP_GOLDEN:-$ROOT/tests/fixtures/runtime/trace.golden.txt}"
INITD="${HP_INITD:-$ROOT/root/etc/init.d/homeproxy}"
TRACE="$WORK/trace.txt"
SANDBOX="$WORK/box"
BIN="$WORK/bin"
# The dnsmasq conf-dir path is absolute in the payload; the section-name
# lookup is keyed off this file name.
DNSMASQ_CONF="/tmp/etc/dnsmasq.conf.hp_test"
rm -rf "$WORK"
mkdir -p "$SANDBOX/etc/homeproxy/scripts/runtime" \
"$SANDBOX/etc/homeproxy/resources" \
"$SANDBOX/var/run/homeproxy" \
"$SANDBOX/dnsmasq" \
"$SANDBOX/sbin" \
"$SANDBOX/init.d" \
"$SANDBOX/etc/crontabs" \
"$BIN"
# The auto-update entry is installed by editing the crontab in place, so the
# file has to exist in the sandbox: on a laptop /etc/crontabs/root does not, the
# `sed -i` fails and the run logs a warning the target never produces. Seeded
# with a stale entry so the delete path is actually exercised.
printf '# existing entry\n0 2 * * * /etc/init.d/acme renew\n0 2 * * * /x #homeproxy_autosetup\n' \
> "$SANDBOX/etc/crontabs/root"
# Stand-ins for the absolute init scripts the runtime calls. They record the
# call and succeed, so the trace is the same everywhere and the "Warning: failed
# to restart ..." lines a laptop produced cannot leak into the golden.
for initd in dnsmasq cron miniupnpd; do
cat > "$SANDBOX/init.d/$initd" <<'EOF'
#!/bin/sh
printf 'init.d/%s %s\n' "$(basename "$0")" "$*" >> "$TRACE"
exit 0
EOF
chmod +x "$SANDBOX/init.d/$initd"
done
# --- the payload the init script expects ---------------------------------
cp "$SCRIPTS/runtime/"*.sh "$SANDBOX/etc/homeproxy/scripts/runtime/"
cp "$SCRIPTS/fw4_names.sh" "$SANDBOX/etc/homeproxy/scripts/fw4_names.sh"
# One line each is enough: the snippet generator rewrites these lists, it
# does not parse them.
printf 'example.com\nfoo.example.org\n' > "$SANDBOX/etc/homeproxy/resources/gfw_list.txt"
printf 'example.cn\n' > "$SANDBOX/etc/homeproxy/resources/china_list.txt"
printf 'proxy.example.net\n' > "$SANDBOX/etc/homeproxy/resources/proxy_list.txt"
# Makes the conf-dir resolver take its success path.
mkdir -p "$(dirname "$DNSMASQ_CONF")"
printf 'conf-dir=%s\n' "$SANDBOX/dnsmasq" > "$DNSMASQ_CONF"
# Makes the ujail branches reachable off-target.
: > "$SANDBOX/sbin/ujail"
chmod +x "$SANDBOX/sbin/ujail"
# --- stubs: every external command the orchestration can reach -----------
stub() {
cat > "$BIN/$1" <<'EOF'
#!/bin/sh
printf '%s %s\n' "$(basename "$0")" "$*" >> "$TRACE"
exit 0
EOF
chmod +x "$BIN/$1"
}
# C2: the functional health probes run `nslookup` and `nc` after the
# process/listener gate, so both have to exist in the sandbox PATH - otherwise
# the host's own binaries would answer and the trace would depend on the host
# (which is the one thing this test exists to prevent). The generic stub
# reports success, so a healthy scenario stays quiet: the probes only log when
# they fail or cannot run.
for cmd in nft fw4 utpl pgrep chown chmod nslookup nc; do
stub "$cmd"
done
# `ip` is the one command whose exit status drives control flow: teardown
# drains duplicate rules with `while ip rule del ...; do :; done`, which only
# terminates because deleting a non-existent rule fails. A stub that always
# succeeds would spin forever.
cat > "$BIN/ip" <<'EOF'
#!/bin/sh
printf 'ip %s\n' "$*" >> "$TRACE"
case "$*" in
*"rule del"*) exit 1 ;;
esac
exit 0
EOF
chmod +x "$BIN/ip"
# The conf-dir resolver derives the dnsmasq section name from `uci show`.
cat > "$BIN/uci" <<'EOF'
#!/bin/sh
printf '%s %s\n' uci "$*" >> "$TRACE"
case "$*" in
*"show dhcp.@dnsmasq[0]"*) echo "dhcp.hp_test= dnsmasq" ;;
esac
exit 0
EOF
chmod +x "$BIN/uci"
# `uname -r` decides the GSO workaround; pin it so traces agree.
cat > "$BIN/uname" <<'EOF'
#!/bin/sh
printf '%s %s\n' uname "$*" >> "$TRACE"
[ "$1" = "-r" ] && echo "6.12.94" || echo "Linux"
EOF
chmod +x "$BIN/uname"
# `sing-box version -n` feeds the version gate and the "started" log line.
cat > "$BIN/sing-box" <<'EOF'
#!/bin/sh
printf '%s %s\n' sing-box "$*" >> "$TRACE"
[ "$1" = "version" ] && echo "1.14.0"
exit 0
EOF
chmod +x "$BIN/sing-box"
# dnsmasq --version decides whether the DNS snippets may carry nftset=. The
# stand-in reports the dnsmasq-full feature set, which is what a device able to
# run this package has; runtime/dns.sh's hp_dnsmasq_has_nftset() reads it. Not
# traced: it is a feature probe, not part of the orchestration under test.
cat > "$BIN/dnsmasq" <<'EOF'
#!/bin/sh
[ "$1" = "--version" ] && {
echo "Dnsmasq version 2.93 Copyright (c) 2000-2024 Simon Kelley"
echo "Compile time options: IPv6 GNU-getopt no-DBus UBus no-i18n no-IDN DHCP DHCPv6 no-Lua TFTP conntrack no-ipset nftset auth DNSSEC no-ID loop-detect inotify dumpfile"
}
exit 0
EOF
chmod +x "$BIN/dnsmasq"
# `ucode -S generate_*.uc` is where the live configuration comes from. The
# stub writes whichever mixed_port the fixture currently declares; whether that
# configuration can actually run is decided from its content by
# `hp_test_broken` below, so the answer follows the live file rather than a
# sticky marker (the rollback copies the old file over the live one without
# regenerating, and the gate has to see that as healthy).
cat > "$BIN/ucode" <<'EOF'
#!/bin/sh
printf 'ucode %s\n' "$*" >> "$TRACE"
for arg in "$@"; do
case "$arg" in
*generate_client.uc)
# One field per line, like the generator's own `%.J` output: the
# runtime reads this file (hp_config_ports, hp_config_port_for_tag), so
# a compact fixture would test a shape production never writes.
printf '{\n\t"log": {},\n\t"inbounds": [\n\t\t{\n\t\t\t"tag": "dns-in",\n\t\t\t"listen_port": %s\n\t\t},\n\t\t{\n\t\t\t"tag": "mixed-in",\n\t\t\t"listen_port": %s\n\t\t}\n\t],\n\t"outbounds": []\n}\n' \
"${HP_CFG_dns_port:-5333}" "${HP_CFG_mixed_port:-5330}" > "$HP_TEST_RUN_DIR/sing-box-c.json"
;;
*generate_server.uc)
printf '{\n\t"log": {},\n\t"inbounds": [],\n\t"outbounds": []\n}\n' > "$HP_TEST_RUN_DIR/sing-box-s.json"
;;
esac
done
exit 0
EOF
chmod +x "$BIN/ucode"
# The fault model: a configuration that declares the port the fixture pretends
# is already taken cannot come up. Keyed on the live file's content, so it
# stays correct when the rollback replaces that file without regenerating.
cat > "$BIN/hp_test_broken" <<'EOF'
#!/bin/sh
side="$1"
[ -n "${HP_TEST_OCCUPIED_PORT:-}" ] || exit 1
[ -f "$HP_TEST_RUN_DIR/sing-box-$side.json" ] || exit 1
# The pattern has to accept the generator's own `%.J` shape, which is
# `"listen_port": 5399` - with a space. The first version hardcoded the compact
# form, so it only injected the fault because the fixture happened to be
# compact; the moment the fixture was made faithful, the gate stopped seeing a
# broken configuration. The trailing (`[^0-9]`|end) keeps 5399 from matching
# 53990.
grep -qE "\"listen_port\"[[:space:]]*:[[:space:]]*$HP_TEST_OCCUPIED_PORT([^0-9]|$)" "$HP_TEST_RUN_DIR/sing-box-$side.json" || exit 1
exit 0
EOF
chmod +x "$BIN/hp_test_broken"
# `ubus call service list` output is not parsed; `jsonfilter` answers from the
# fixture state instead, which keeps the stub independent of jsonfilter's real
# expression language. ubus does not write to the trace itself: its output
# always goes through a pipe, so a second writer would race the trace order.
cat > "$BIN/ubus" <<'EOF'
#!/bin/sh
exit 0
EOF
chmod +x "$BIN/ubus"
# procd's view of the instance. Down + exit_code 1 whenever the live
# configuration for that side cannot bind - which is what procd reports while
# it restarts a crashing instance.
cat > "$BIN/jsonfilter" <<'EOF'
#!/bin/sh
printf 'jsonfilter %s\n' "$*" >> "$TRACE"
side="c"
case "$*" in
*"sing-box-s"*) side="s" ;;
esac
down=no
hp_test_broken "$side" && down=yes
case "$*" in
*".instances["*)
if [ "$down" = "yes" ]; then
printf '{"running":false,"exit_code":1}\n'
else
printf '{"running":true}\n'
fi
;;
*"@.running"*)
[ "$down" = "yes" ] && echo "false" || echo "true"
;;
*"@.exit_code"*)
[ "$down" = "yes" ] && echo "1"
;;
esac
exit 0
EOF
chmod +x "$BIN/jsonfilter"
# The listen table. The ports are taken from the LIVE configuration, the way
# the real gate derives them, and the occupied port is held by the process that
# took it - the case that makes a naive "is the port in the table?" check
# useless.
cat > "$BIN/netstat" <<'EOF'
#!/bin/sh
printf 'netstat %s\n' "$*" >> "$TRACE"
echo "Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name"
live="$HP_TEST_RUN_DIR/sing-box-c.json"
ports=$(grep -o '"listen_port"[^0-9]*[0-9]*' "$live" 2>/dev/null | grep -o '[0-9]*$' | tr '\n' ' ')
[ -n "$ports" ] || ports="${HP_CFG_mixed_port:-5330} ${HP_CFG_dns_port:-5333}"
for p in $ports; do
if [ -n "${HP_TEST_OCCUPIED_PORT:-}" ] && [ "$p" = "$HP_TEST_OCCUPIED_PORT" ]; then
printf 'tcp 0 0 :::%s :::* LISTEN 77/socat\n' "$p"
else
printf 'tcp 0 0 :::%s :::* LISTEN 4242/sing-box\n' "$p"
fi
done
exit 0
EOF
chmod +x "$BIN/netstat"
# The WAN wait polls `ip route show default`, `ip -6 route show default` and
# `ifstatus wan`; the first two produce no output through the generic stub, so
# ifstatus has to report the interface up or every scenario would spin for a
# minute. `sleep` is neutralised for the same reason: the 15-sample health
# budget must not cost 15 real seconds.
cat > "$BIN/ifstatus" <<'EOF'
#!/bin/sh
printf 'ifstatus %s\n' "$*" >> "$TRACE"
echo '{ "up": true }'
exit 0
EOF
chmod +x "$BIN/ifstatus"
cat > "$BIN/sleep" <<'EOF'
#!/bin/sh
printf 'sleep %s\n' "$*" >> "$TRACE"
exit 0
EOF
chmod +x "$BIN/sleep"
# --- harness: UCI + procd + the log sink ---------------------------------
# Sourced *into* the same shell as the init script, exactly like rc.common
# does, so the init script's globals and functions resolve as on a router.
cat > "$WORK/harness.sh" <<'EOF'
# Fixture UCI values. config_get assigns through its first argument - an
# OpenWrt config_get is a variable-setting function, not a getter.
HP_CFG_routing_mode="bypass_mainland_china"
HP_CFG_proxy_mode="tun"
HP_CFG_main_node="n1"
HP_CFG_main_udp_node="nil"
HP_CFG_default_outbound="direct-out"
HP_CFG_ipv6_support="0"
HP_CFG_auto_update="0"
HP_CFG_auto_update_time="2"
HP_CFG_server_enabled="0"
HP_CFG_table_mark="100"
HP_CFG_tproxy_mark="101"
HP_CFG_tun_mark="102"
HP_CFG_tun_name="singtun0"
HP_CFG_dns_port="5333"
HP_CFG_mixed_port="5330"
config_load() { printf 'config_load %s\n' "$*" >> "$TRACE"; }
# The fixture is keyed by "<section>_<option>" (server_enabled, mixed_port, ...)
# while config_get/config_get_bool receive section and option separately. The
# lookup has to try the section-qualified name first: reading only the bare
# option name meant `server_enabled=1` on a scenario was silently ignored -
# scenario B's "with server" registered no server instance at all - which is a
# coverage hole, not a fixture convenience.
config_get() {
__cg_var="$1"; __cg_sec="$2"; __cg_opt="$3"; __cg_def="$4"
eval "__cg_val=\"\${HP_CFG_${__cg_sec}_${__cg_opt}:-}\""
[ -n "$__cg_val" ] || eval "__cg_val=\"\${HP_CFG_${__cg_opt}:-}\""
[ -n "$__cg_val" ] || __cg_val="$__cg_def"
eval "$__cg_var=\"\$__cg_val\""
}
config_get_bool() {
__cg_var="$1"; __cg_sec="$2"; __cg_opt="$3"; __cg_def="$4"
eval "__cg_val=\"\${HP_CFG_${__cg_sec}_${__cg_opt}:-}\""
[ -n "$__cg_val" ] || eval "__cg_val=\"\${HP_CFG_${__cg_opt}:-}\""
[ -n "$__cg_val" ] || __cg_val="$__cg_def"
eval "$__cg_var=\"\$__cg_val\""
}
# procd is not available off-target; record the calls instead, so a lost or
# reordered instance parameter shows up as a trace diff.
procd_open_instance() { printf 'procd_open_instance %s\n' "$*" >> "$TRACE"; }
procd_close_instance() { printf 'procd_close_instance\n' >> "$TRACE"; }
procd_set_param() { printf 'procd_set_param %s\n' "$*" >> "$TRACE"; }
procd_append_param() { printf 'procd_append_param %s\n' "$*" >> "$TRACE"; }
procd_add_jail() { printf 'procd_add_jail %s\n' "$*" >> "$TRACE"; }
procd_add_jail_mount() { printf 'procd_add_jail_mount %s\n' "$*" >> "$TRACE"; }
procd_add_jail_mount_rw() { printf 'procd_add_jail_mount_rw %s\n' "$*" >> "$TRACE"; }
procd_add_reload_trigger() { printf 'procd_add_reload_trigger %s\n' "$*" >> "$TRACE"; }
procd_add_interface_trigger() { printf 'procd_add_interface_trigger %s\n' "$*" >> "$TRACE"; }
# rc.common's lifecycle, modelled faithfully:
#
# start() { rc_procd start_service "$@"; service_started; }
# stop() { stop_service "$@"; procd_kill ...; service_stopped; }
#
# start_service only *registers* the procd instances; procd runs them when the
# service is closed. That is why the health gate lives in service_started()
# and why this harness has to call it - a harness that reported the instances
# healthy as soon as they were registered hid exactly that bug once.
start() { start_service; service_started; }
stop() { stop_service; service_stopped; }
# The real log() stamps a timestamp and appends to a file, which would make
# the trace nondeterministic.
hp_trace_log() { printf 'log %s\n' "$*" >> "$TRACE"; }
EOF
# --- run one scenario ----------------------------------------------------
# $1 scenario name, $2 the action (start|reload), then the HP_CFG_* overrides.
run_scenario() {
scenario="$1"; shift
action="$1"; shift
rm -rf "$SANDBOX/var/run/homeproxy" "$SANDBOX/dnsmasq/dnsmasq-homeproxy.d" \
"$SANDBOX/dnsmasq/dnsmasq-homeproxy.conf" "$SANDBOX/etc/homeproxy/cache.db" \
"$SANDBOX/etc/homeproxy/ruleset" "$SANDBOX/etc/homeproxy/certs"
mkdir -p "$SANDBOX/var/run/homeproxy" "$SANDBOX/dnsmasq"
printf '\n===== scenario: %s (%s) =====\n' "$scenario" "$action" >> "$TRACE"
(
PATH="$BIN:$PATH"; export PATH
TRACE="$TRACE"; export TRACE
HP_TEST_RUN_DIR="$SANDBOX/var/run/homeproxy"; export HP_TEST_RUN_DIR
# harness.sh assigns the fixture defaults, so it has to be sourced
# BEFORE the per-scenario overrides - the other order silently
# resets every override and makes all scenarios run the same
# configuration.
. "$WORK/harness.sh"
for kv in "$@"; do
eval "HP_CFG_${kv%%=*}=\"${kv#*=}\""
export "HP_CFG_${kv%%=*}"
done
export HP_CFG_routing_mode HP_CFG_proxy_mode HP_CFG_main_node \
HP_CFG_main_udp_node HP_CFG_default_outbound HP_CFG_ipv6_support \
HP_CFG_auto_update HP_CFG_auto_update_time HP_CFG_server_enabled \
HP_CFG_table_mark HP_CFG_tproxy_mark HP_CFG_tun_mark \
HP_CFG_tun_name HP_CFG_dns_port HP_CFG_mixed_port \
HP_TEST_OCCUPIED_PORT HP_TEST_DOWN
# A certificate on disk makes the per-file chown/chmod in
# hp_prepare_runtime_files() visible in the trace: an uploaded key
# lands as root:0600 and the jailed server needs it handed to the
# sing-box user, or it cannot start.
if [ "$HP_CFG_server_enabled" = "1" ]; then
mkdir -p "$SANDBOX/etc/homeproxy/certs"
printf '%s\n' '-----BEGIN PRIVATE KEY-----' \
> "$SANDBOX/etc/homeproxy/certs/server_privatekey.pem"
fi
. "$WORK/initd.sh"
# Override the log sink the init script just defined.
log() { hp_trace_log "$*"; }
# Scenario D needs a good configuration in place first, so that the
# rollback has a real target to restore. The candidate is switched
# to the already-taken port only for the reload.
if [ "$action" = "reload" ]; then
unset HP_TEST_OCCUPIED_PORT
start
printf 'prime start rc=%d\n' "$?" >> "$TRACE"
if [ "${HP_TEST_DISABLE_CLIENT_AFTER:-0}" = "1" ]; then
# The second phase turns the client off instead of breaking
# its port (scenario H).
HP_CFG_main_node=nil
export HP_CFG_main_node
else
HP_CFG_mixed_port="${HP_TEST_OCCUPIED_AFTER:-5399}"
HP_TEST_OCCUPIED_PORT="${HP_TEST_OCCUPIED_AFTER:-5399}"
export HP_CFG_mixed_port HP_TEST_OCCUPIED_PORT
fi
reload_service
printf 'reload_service rc=%d\n' "$?" >> "$TRACE"
elif [ "$action" = "start-twice" ]; then
# Scenario I: two plain starts in a row, with the client switched
# off in between. This is LuCI's Start button on a router whose
# node was just removed - a *start*, not a reload, which is the
# path that has no rollback.
start
printf 'prime start rc=%d\n' "$?" >> "$TRACE"
HP_CFG_main_node=nil
export HP_CFG_main_node
start
printf 'start rc=%d\n' "$?" >> "$TRACE"
else
start
printf 'start rc=%d\n' "$?" >> "$TRACE"
fi
stop
printf 'stop rc=%d\n' "$?" >> "$TRACE"
) >/dev/null 2>&1
# Record the files that ended up in the output directories, with
# contents, so a changed snippet or a lost known-good copy is visible.
for f in "$SANDBOX/var/run/homeproxy"/* \
"$SANDBOX/var/run/homeproxy/known-good"/* \
"$SANDBOX/etc/homeproxy/cache.db" \
"$SANDBOX/dnsmasq"/* \
"$SANDBOX/dnsmasq/dnsmasq-homeproxy.d"/*; do
[ -f "$f" ] || continue
printf 'file %s: ' "${f#"$SANDBOX"/}" >> "$TRACE"
tr '\n' '|' < "$f" >> "$TRACE"
printf '\n' >> "$TRACE"
done
}
# --- stage the init script under test ------------------------------------
# Rewrite the absolute paths to the sandbox. Anchor guards: a silent no-op
# rewrite would make this test compare two unsandboxed runs and pass for the
# wrong reason.
#
# `/sbin/ujail` lives in init.d before PR-05 and in runtime/service.sh after
# it, so the rewrite has to cover both.
# The runtime modules talk to a few ABSOLUTE paths, and those are the test's
# only host dependence: `/etc/init.d/dnsmasq` exists on a router (so the real
# script runs, with its own side effects) and not on a laptop, and `/sbin/ujail`
# likewise. Rewriting them into the sandbox is what makes the trace identical
# on macOS, on the CI runner and on the target - the first on-target run differed
# from line 11 purely because dnsmasq's init script was there to run.
sandbox_paths() {
sed -e "s#-x \"/sbin/ujail\"#-x \"$SANDBOX/sbin/ujail\"#g" \
-e "s#/etc/init.d/dnsmasq#$SANDBOX/init.d/dnsmasq#g" \
-e "s#/etc/init.d/cron#$SANDBOX/init.d/cron#g" \
-e "s#/etc/init.d/miniupnpd#$SANDBOX/init.d/miniupnpd#g" \
-e "s#/etc/crontabs/root#$SANDBOX/etc/crontabs/root#g" \
"$1" > "$1.tmp" && mv "$1.tmp" "$1"
}
sed -e "s#^HP_DIR=\"/etc/homeproxy\"#HP_DIR=\"$SANDBOX/etc/homeproxy\"#" \
-e "s#^RUN_DIR=\"/var/run/homeproxy\"#RUN_DIR=\"$SANDBOX/var/run/homeproxy\"#" \
"$INITD" > "$WORK/initd.sh"
sandbox_paths "$WORK/initd.sh"
for module in "$SANDBOX/etc/homeproxy/scripts/runtime"/*.sh; do
sandbox_paths "$module"
done
for anchor in "HP_DIR=\"$SANDBOX/etc/homeproxy\"" \
"RUN_DIR=\"$SANDBOX/var/run/homeproxy\""; do
if ! grep -qF "$anchor" "$WORK/initd.sh"; then
echo "FAIL: could not sandbox $INITD - missing anchor: $anchor"
rm -f "$DNSMASQ_CONF"
exit 1
fi
done
for anchor in "$SANDBOX/sbin/ujail" "$SANDBOX/init.d/dnsmasq" "$SANDBOX/init.d/cron" \
"$SANDBOX/etc/crontabs/root"; do
if ! grep -qF "$anchor" "$WORK/initd.sh" "$SANDBOX/etc/homeproxy/scripts/runtime"/*.sh; then
echo "FAIL: could not sandbox $anchor - the trace would depend on the host"
rm -f "$DNSMASQ_CONF"
exit 1
fi
done
# Scenario A: TUN client, bypass_mainland_china, no server, no ipv6.
run_scenario "A-tun-bypass-client-only" start \
proxy_mode=tun routing_mode=bypass_mainland_china \
main_node=n1 main_udp_node=nil server_enabled=0 ipv6_support=0
# Scenario B: tproxy client with a UDP node + ipv6, gfwlist, server enabled,
# auto-update cron on.
run_scenario "B-tproxy-gfwlist-ipv6-with-server" start \
proxy_mode=redirect_tproxy routing_mode=gfwlist \
main_node=n1 main_udp_node=u1 server_enabled=1 ipv6_support=1 \
auto_update=1 auto_update_time=3
# Scenario C: neither side configured -> the early return must stay early.
run_scenario "C-nothing-configured" start \
proxy_mode=tun routing_mode=bypass_mainland_china \
main_node=nil main_udp_node=nil server_enabled=0 ipv6_support=0
# Scenario D: the P0 regression. A healthy configuration is started and
# recorded, then a candidate whose mixed_port is already taken is reloaded.
# The gate must reject it, the previous known-good must survive, and the
# rollback must bring the service back on the old configuration.
run_scenario "D-health-gate-rollback" reload \
proxy_mode=tun routing_mode=bypass_mainland_china \
main_node=n1 main_udp_node=nil server_enabled=0 ipv6_support=0 \
HP_TEST_OCCUPIED_AFTER=5399
# Scenario E: custom routing mode. It is the mode whose start path has to
# prepare both runtime files - the ruleset directory and the shared cache.db -
# and whose client jail has to carry HP_DIR, because a local rule-set may point
# anywhere under it. The trace records all three, so this scenario fails if the
# custom client stops being jailed or stops getting its runtime files.
run_scenario "E-custom-routing-runtime-files" start \
proxy_mode=tun routing_mode=custom \
main_node=n1 main_udp_node=nil default_outbound=direct-out \
server_enabled=0 ipv6_support=0
# Scenario F: custom routing with no default outbound. The client's "main node"
# reference is mode-dependent (config.main_node vs routing.default_outbound), and
# a regression that reads the wrong UCI key in this mode makes an unconfigured
# router look configured: it starts a client whose generated route block has no
# `final`, so every intercepted connection leaves through sing-box's built-in
# direct outbound instead of the proxy. The service must refuse to start.
run_scenario "F-custom-routing-nothing-configured" start \
proxy_mode=tun routing_mode=custom \
main_node=n1 main_udp_node=nil default_outbound=nil \
server_enabled=0 ipv6_support=0
# Scenario G: a plain `start` whose configuration cannot come up. The
# intercept rules are installed *before* the health gate runs, and the start
# path has no rollback (that is reload-only) - so the gate failure has to
# release them itself. Otherwise the router's own clients keep being
# redirected to a listener that is not there: the LAN loses DNS and TCP, not
# just the proxy. HP_TEST_OCCUPIED_PORT makes the generated configuration
# unbindable, and the env-prefix assignment is how it reaches the scenario
# (run_scenario's key=value arguments are HP_CFG_* overrides).
HP_TEST_OCCUPIED_PORT=5399 run_scenario "G-start-health-gate-failure" start \
proxy_mode=redirect_tproxy routing_mode=bypass_mainland_china \
main_node=n1 main_udp_node=nil server_enabled=0 ipv6_support=0 \
mixed_port=5399
# Scenario H: two plain starts, the second with the client switched off but the
# server still enabled (the node was removed, the server side stayed). LuCI's
# Start button takes this path, not reload, and it has no rollback - so the
# intercept layer the first start installed has to be released by the start
# itself. The "neither side is configured" branch does not cover this case:
# with the server still on, the run gets past it and would leave the LAN
# pointed at the client's dead redirect port. This is also what makes the
# reload path's "keep the DNS layer across the stop" safe: without it,
# disabling the client would leave the snippets installed forever.
# Scenario I: the DNS snippet writer fails (an unusable infra.dns_port). The
# snippets are half of the intercept layer - without them the LAN keeps
# resolving through the ISP while the nft rules still send its DNS to sing-box's
# dns-in - and the writer returns non-zero for exactly this case rather than
# installing an empty snippet. A start must say so and must not promote the
# configuration to known-good, which is the same rule a failed firewall apply
# already follows.
run_scenario "I-dns-snippet-write-failure" start \
proxy_mode=tun routing_mode=bypass_mainland_china \
main_node=n1 main_udp_node=nil server_enabled=0 ipv6_support=0 \
mixed_port=5398 dns_port="70000"
run_scenario "H-start-disables-client" start-twice \
proxy_mode=redirect_tproxy routing_mode=bypass_mainland_china \
main_node=n1 main_udp_node=nil server_enabled=1 ipv6_support=0
rm -f "$DNSMASQ_CONF"
# Strip the sandbox prefix so the trace is portable.
sed "s#$SANDBOX/##g" "$TRACE" > "$TRACE.norm"
if [ "${HP_UPDATE_GOLDEN:-0}" = "1" ]; then
mkdir -p "$(dirname "$GOLDEN")"
cp "$TRACE.norm" "$GOLDEN"
echo "PASS: golden trace written to $GOLDEN ($(wc -l < "$GOLDEN") lines)"
exit 0
fi
if [ ! -f "$GOLDEN" ]; then
echo "FAIL: golden trace $GOLDEN is missing"
exit 1
fi
# The decision must not depend on `diff`. This test also runs on the target
# (tests/ucode/run.sh stages it there), and busybox has `cmp` but often no
# `diff` - the first on-target run failed with "diff: not found" and looked
# exactly like an orchestration change. The golden snapshot tests already do
# it this way: `cmp` decides, `diff` only formats the diagnosis.
# Scenario E specifically: the golden comparison above would report a difference
# without saying which scenario caused it, and this is the one whose whole point
# is the absence of a warning. It has to run after TRACE.norm is built - the
# first version of this check read the file before it existed and therefore
# always passed.
if awk '/^===== scenario: E-custom-routing-runtime-files/,0' "$TRACE.norm" \
| grep -q "failed to change the ownership of the runtime files"; then
echo "FAIL: custom mode reports a chown failure for a runtime file it should have created"
awk '/^===== scenario: E-custom-routing-runtime-files/,0' "$TRACE.norm" \
| grep -n "failed to change the ownership" | head -2
exit 1
fi
# Scenario F pins the mode-dependent read itself. It has to assert on the
# "not configured" log rather than on the generated file, because the failure
# mode is a client that starts and produces a config with no route.final - it
# looks healthy from every other angle (process up, ports bound).
if awk '/^===== scenario: F-custom-routing-nothing-configured/,0' "$TRACE.norm" \
| grep -q "^log Neither client nor server is configured\. Service not started\.$"; then
echo "PASS: custom mode with no default outbound does not start a client"
else
echo "FAIL: custom mode with default_outbound=nil still started a client"
awk '/^===== scenario: F-custom-routing-nothing-configured/,0' "$TRACE.norm" | head -25
exit 1
fi
# A3: a reload generates each enabled side exactly once, and activates that
# artifact. Before the candidate staging this scenario ran the client generator
# twice per reload - once as the preflight, once again from start_service after
# stop_service() had deleted the validated file - so the bytes that ran were not
# the bytes that were checked. Counted inside the reload rather than over the
# whole scenario, because the priming start legitimately generates once itself.
reload_generates=$(awk '/^log Reloading service\.\.\.$/,/^reload_service rc=/' "$TRACE.norm" \
| grep -c '^ucode -S etc/homeproxy/scripts/generate_client\.uc$')
if [ "$reload_generates" = "1" ]; then
echo "PASS: a reload generates the client configuration exactly once"
else
echo "FAIL: a reload ran the client generator $reload_generates time(s), expected 1"
awk '/^log Reloading service\.\.\.$/,/^reload_service rc=/' "$TRACE.norm" \
| grep -n 'ucode -S\|Activating the' | head -5
exit 1
fi
if awk '/^log Reloading service\.\.\.$/,/^reload_service rc=/' "$TRACE.norm" \
| grep -q '^log Activating the client configuration validated by the reload preflight\.$'; then
echo "PASS: the reload activates the configuration it validated"
else
echo "FAIL: the reload did not activate its validated candidate"
exit 1
fi
# Scenario G asserts the behaviour, not just its trace: a start whose client
# cannot come up must release the intercept layer (otherwise the LAN keeps
# being redirected into it), and a healthy start must not pay for that.
if awk '/^===== scenario: G-start-health-gate-failure/,/^===== scenario: H-/' "$TRACE.norm" \
| grep -q "^log Reverting the intercept layer"; then
echo "PASS: a failed start releases the intercept layer"
else
echo "FAIL: a failed start left the redirect rules and the DNS snippets installed"
awk '/^===== scenario: G-start-health-gate-failure/,/^===== scenario: H-/' "$TRACE.norm" | head -40
exit 1
fi
if awk '/^===== scenario: G-start-health-gate-failure/,/^===== scenario: H-/' "$TRACE.norm" \
| grep -q "^file var/run/homeproxy/known-good/"; then
echo "FAIL: a start that failed the health gate recorded a known-good configuration"
exit 1
else
echo "PASS: a failed start records no known-good configuration"
fi
# Scenario I: a failed snippet install must be reported and must not promote the
# configuration. The DNS layer is half the intercept, and the writer returns
# non-zero instead of installing an empty snippet - so the start has to notice.
SCEN_I="/^===== scenario: I-dns-snippet-write-failure/"
if awk "$SCEN_I,0" "$TRACE.norm" | grep -q "^log Error: the dnsmasq snippets were not installed"; then
echo "PASS: a failed DNS snippet install is reported by the start"
else
echo "FAIL: a failed DNS snippet install was silently ignored"
awk "$SCEN_I,0" "$TRACE.norm" | head -30
exit 1
fi
# The known-good pair persists across scenarios, so the dump shows whatever the
# last successful start recorded. Scenario I generates a configuration nothing
# else does (mixed_port 5398), so the copy is identifiable by content: if the
# dump holds 5398 the failed start promoted over it, if it holds anything else
# the previous known-good survived - which is the required behaviour.
if awk "$SCEN_I,0" "$TRACE.norm" \
| grep '^file var/run/homeproxy/known-good/sing-box-c.json:' \
| grep -q '5398'; then
echo "FAIL: a start whose DNS snippets did not install recorded a known-good configuration"
exit 1
else
echo "PASS: a start whose DNS snippets did not install records no known-good"
fi
if awk '/^===== scenario: A-tun-bypass-client-only/,/^===== scenario: B-/' "$TRACE.norm" \
| grep -q "^log Reverting the intercept layer"; then
echo "FAIL: a healthy start released the intercept layer it had just installed"
exit 1
else
echo "PASS: a healthy start does not release the intercept layer"
fi
# P1-8: a reload must leave the DNS layer alone. Its stop used to remove the
# snippets and restart dnsmasq, then start put them back and restarted it again
# - a plaintext DNS window and a full cache flush for a configuration that did
# not change. The writer's content comparison is what makes keeping them safe:
# the re-render below has to report that nothing changed.
if awk '/^===== scenario: D-health-gate-rollback/,/^===== scenario: E-/' "$TRACE.norm" \
| grep -q "^log dnsmasq snippets unchanged, skipping the restart\.$"; then
echo "PASS: a reload keeps the DNS snippets and does not restart dnsmasq for them"
else
echo "FAIL: a reload re-rendered the DNS layer instead of keeping it"
awk '/^===== scenario: D-health-gate-rollback/,/^===== scenario: E-/' "$TRACE.norm" \
| grep -n "dnsmasq" | head -8
exit 1
fi
# Scenario H: the client was switched off and the start has to take the
# intercept layer down with it (LuCI's Start button, no rollback involved).
if awk '/^===== scenario: H-start-disables-client/,0' "$TRACE.norm" \
| grep -q "^log Reverting the intercept layer"; then
echo "PASS: a start that finds the client switched off releases the intercept layer"
else
echo "FAIL: switching the client off left the redirect rules and DNS snippets in place"
awk '/^===== scenario: H-start-disables-client/,0' "$TRACE.norm" | tail -40
exit 1
fi
if awk '/^===== scenario: H-start-disables-client/,0' "$TRACE.norm" \
| grep -q "^file dnsmasq/dnsmasq-homeproxy"; then
echo "FAIL: the dnsmasq snippets survived the client being switched off"
awk '/^===== scenario: H-start-disables-client/,0' "$TRACE.norm" | grep "^file dnsmasq"
exit 1
else
echo "PASS: the dnsmasq snippets are gone after the client is switched off"
fi
if cmp -s "$GOLDEN" "$TRACE.norm"; then
echo "PASS: runtime orchestration matches $(basename "$GOLDEN")"
# Counted from the golden rather than hardcoded: a scenario added without
# this line would quietly claim the old number.
echo " ($(wc -l < "$GOLDEN") trace lines across $(grep -c '^===== scenario: ' "$GOLDEN") scenarios)"
exit 0
fi
echo "FAIL: the orchestration changed:"
if command -v diff > "/dev/null" 2>&1; then
diff -u "$GOLDEN" "$TRACE.norm" | head -80
else
# No diff here: name the first differing lines and show both sides, which
# is enough to diagnose without the tool.
awk '
NR == FNR { golden[FNR] = $0; n = FNR; next }
{
if ($0 != golden[FNR] && shown < 20) {
printf " line %d\n golden: %s\n actual: %s\n", FNR, golden[FNR], $0;
shown++;
}
}
END {
if (n > FNR)
printf " (golden has %d more lines than the trace)\n", n - FNR;
}
' "$GOLDEN" "$TRACE.norm"
fi
exit 1
exit 0