#!/bin/sh # SPDX-License-Identifier: GPL-2.0-only # # Runtime orchestration trace test. # # Drives root/etc/init.d/homeproxy-pro 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 [work-dir] # # HP_INITD= drive another init script # HP_GOLDEN= compare against another baseline # HP_UPDATE_GOLDEN=1 rewrite the baseline instead of comparing # # The baseline is a Linux artifact, and refreshing it from macOS writes # platform noise into it. The `uname` stub (line ~217) traces every call, and # something in the sandbox asks for `uname -s` only on Darwin: a macOS run # produces 21 extra "uname -s" lines and the comparison then fails for a # reason that has nothing to do with the code. Verified 2026-09-29: deleting # the `uname -s` lines from a macOS trace reproduces this file byte for byte, # so the fix when a macOS run is the only way to produce a trace is # # HP_UPDATE_GOLDEN=1 HP_GOLDEN=/tmp/trace.txt sh tests/runtime/test_runtime_extraction.sh . # grep -v '^uname -s$' /tmp/trace.txt > tests/fixtures/runtime/trace.golden.txt # # .github/workflows/golden-refresh.yml does this on Linux, which is the path # that should be preferred. 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-pro/scripts" GOLDEN="${HP_GOLDEN:-$ROOT/tests/fixtures/runtime/trace.golden.txt}" INITD="${HP_INITD:-$ROOT/root/etc/init.d/homeproxy-pro}" 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-pro/scripts/runtime" \ "$SANDBOX/etc/homeproxy-pro/resources" \ "$SANDBOX/var/run/homeproxy-pro" \ "$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-pro/scripts/runtime/" cp "$SCRIPTS/fw4_names.sh" "$SANDBOX/etc/homeproxy-pro/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-pro/resources/gfw_list.txt" printf 'example.cn\n' > "$SANDBOX/etc/homeproxy-pro/resources/china_list.txt" printf 'proxy.example.net\n' > "$SANDBOX/etc/homeproxy-pro/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 "
_