mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 16:45:13 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
f4559bbed1
commit
065763f7fd
673 files changed
+261285
-34160
No files matched your search
@@ -0,0 +1,260 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/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
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
#!/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
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,882 @@
|
||||
#!/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
|
||||
Reference in new issue
Block a user