Files
op-packages/luci-app-homeproxy-pro/tests/runtime/test_config_transaction.sh
T

383 lines
14 KiB
Bash

#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Runtime configuration-transaction tests.
#
# These cover the pieces init.d/homeproxy-pro 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-pro/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"
# hp_restore_known_good_uci: the snapshot written by hp_promote_known_good.
# The init.d/homeproxy-pro rollback path reads $GOOD_DIR/uci-snapshot.txt
# BEFORE stop;start, otherwise start_service installs the failing layer
# on top of the rolled-back sing-box bytes (review P1-3, 2026-09-28).
# The helper itself is a uci loop; we drive it with a stub `uci` so the
# test does not depend on the device's uci-tools.
echo "== UCI snapshot restore (review P1-3) =="
SNAP_DIR="$WORK/run/known-good"
mkdir -p "$SNAP_DIR"
SNAP="$SNAP_DIR/uci-snapshot.txt"
BIN="$WORK/bin"
mkdir -p "$BIN"
HP_TEST_UCI_OUT="$WORK/uci.out"
: > "$HP_TEST_UCI_OUT"
export HP_TEST_UCI_OUT
# Stub `uci` so the helper can be driven without real uci-tools. The
# stub records every call into $HP_TEST_UCI_OUT (passed via env, not a
# shell variable: the helper launches uci as a child process and shell
# variables of the parent do not reach it).
write_uci_stub() {
cat > "$BIN/uci" <<STUB
#!/bin/sh
echo "\$@" >> "\$HP_TEST_UCI_OUT"
exit 0
STUB
chmod +x "$BIN/uci"
}
# No snapshot at all -> 1 (best-effort caller logs and moves on).
rm -f "$SNAP"
PATH="$BIN:$PATH" hp_restore_known_good_uci "$SNAP_DIR" >/dev/null 2>&1
expect "uci restore: missing snapshot returns 1" "$?" "1"
# Happy path: snapshot present, all keys applied, commit called.
# The snapshot format carries the UCI type, because `uci set` on a list option
# stores a string and the firewall validators then reject it (review
# 2026-09-29 review): lists are replayed
# with `uci delete` + `uci add_list`.
: > "$HP_TEST_UCI_OUT"
write_uci_stub
printf 'homeproxy-pro.config.proxy_mode=tproxy\n' > "$SNAP"
printf 'homeproxy-pro.config.routing_mode=bypass_mainland_china\n' >> "$SNAP"
printf 'homeproxy-pro.infra.self_mark=100\n' >> "$SNAP"
printf 'homeproxy-pro.infra.tun_name=singtun0\n' >> "$SNAP"
printf 'homeproxy-pro.infra.tun_addr4=172.16.0.1/30\n' >> "$SNAP"
printf 'list homeproxy-pro.control.lan_proxy_ipv4_ips=192.168.1.10,192.168.1.11\n' >> "$SNAP"
printf 'list homeproxy-pro.control.wan_proxy_ipv4_ips=\n' >> "$SNAP"
PATH="$BIN:$PATH" hp_restore_known_good_uci "$SNAP_DIR"
expect "uci restore: happy path returns 0" "$?" "0"
expect "uci restore: 5 scalars applied" \
"$(grep -c '^set homeproxy-pro\.' "$HP_TEST_UCI_OUT")" "5"
expect "uci restore: proxy_mode applied" \
"$(grep -c '^set homeproxy-pro.config.proxy_mode=tproxy$' "$HP_TEST_UCI_OUT")" "1"
# The phantom key that used to be in the snapshot: nothing reads
# `infra.tun_address`, and arch-guard guard 43 now fails if it comes back.
expect "uci restore: tun_addr4 applied (not the phantom tun_address)" \
"$(grep -c '^set homeproxy-pro.infra.tun_addr4=172.16.0.1/30$' "$HP_TEST_UCI_OUT")" "1"
# A list must never go through `uci set`: that would store a string and
# ipv4_to_nftarr() would return null, silently dropping the ACL rule.
expect "uci restore: list is deleted before it is re-added" \
"$(grep -c '^delete homeproxy-pro.control.lan_proxy_ipv4_ips$' "$HP_TEST_UCI_OUT")" "1"
expect "uci restore: list elements re-added one by one" \
"$(grep -c '^add_list homeproxy-pro.control.lan_proxy_ipv4_ips=' "$HP_TEST_UCI_OUT")" "2"
expect "uci restore: first list element" \
"$(grep -c '^add_list homeproxy-pro.control.lan_proxy_ipv4_ips=192.168.1.10$' "$HP_TEST_UCI_OUT")" "1"
expect "uci restore: second list element" \
"$(grep -c '^add_list homeproxy-pro.control.lan_proxy_ipv4_ips=192.168.1.11$' "$HP_TEST_UCI_OUT")" "1"
expect "uci restore: no list key is ever passed to uci set" \
"$(grep -c '^set list ' "$HP_TEST_UCI_OUT")" "0"
# An empty recorded list means "absent or empty": the delete is the whole
# operation, so a list the failing configuration added does not survive.
expect "uci restore: empty list still dropped" \
"$(grep -c '^delete homeproxy-pro.control.wan_proxy_ipv4_ips$' "$HP_TEST_UCI_OUT")" "1"
expect "uci restore: empty list adds no element" \
"$(grep -c '^add_list homeproxy-pro.control.wan_proxy_ipv4_ips=' "$HP_TEST_UCI_OUT")" "0"
expect "uci restore: commit homeproxy-pro called" \
"$(grep -c 'commit homeproxy-pro$' "$HP_TEST_UCI_OUT")" "1"
# Empty file: no sets, no commit -> 1.
: > "$HP_TEST_UCI_OUT"
: > "$SNAP"
PATH="$BIN:$PATH" hp_restore_known_good_uci "$SNAP_DIR"
expect "uci restore: empty snapshot returns 1" "$?" "1"
expect "uci restore: empty snapshot writes no writes" \
"$(wc -l < "$HP_TEST_UCI_OUT" | tr -d ' ')" "0"
# Blank lines are skipped.
: > "$HP_TEST_UCI_OUT"
printf '\n# comment\n' > "$SNAP"
printf 'homeproxy-pro.config.proxy_mode=redirect_tproxy\n' >> "$SNAP"
printf '\n\n' >> "$SNAP"
PATH="$BIN:$PATH" hp_restore_known_good_uci "$SNAP_DIR"
expect "uci restore: blank lines skipped returns 0" "$?" "0"
expect "uci restore: only the real key applied" \
"$(grep -c 'homeproxy-pro\.' "$HP_TEST_UCI_OUT")" "1"
# uci set succeeds, uci commit fails -> helper returns 1, no commit.
cat > "$BIN/uci" <<STUB
#!/bin/sh
echo "\$@" >> "\$HP_TEST_UCI_OUT"
case "\$1" in
set) exit 0 ;;
commit) exit 1 ;;
esac
STUB
chmod +x "$BIN/uci"
: > "$HP_TEST_UCI_OUT"
printf 'homeproxy-pro.config.proxy_mode=tproxy\n' > "$SNAP"
PATH="$BIN:$PATH" hp_restore_known_good_uci "$SNAP_DIR"
expect "uci restore: failed commit returns 1" "$?" "1"
# The stub still echoes the failed command (it writes first, then exits
# non-zero), so what we really want to assert is "the helper did not
# commit *despite* calling uci commit" - check that no UCI_OUT line shows
# a successful commit pattern by looking at the helper's exit code: 1
# already says "no commit happened". The grep here is a sanity probe
# for the stub itself, not the helper - leave it informational.
expect "uci restore: stub recorded the failed commit attempt" \
"$(grep -c 'commit homeproxy-pro$' "$HP_TEST_UCI_OUT")" "1"
rm -f "$BIN/uci"
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