Repository navigation
qa: harden blacklist test process and runner boundaries - #87
Merged
Merged
Conversation
- Keep the pubkey, SQL and URLs out of argv on both ends: the config edit reads the pubkey from ssh stdin and passes it to jq/python3 via the environment, curl reads its URL from a -K - config on stdin, and grep reads its patterns from mode-600 files in the run's mode-700 temp dir. - Remove the ADMIN_API_TOKEN path: /api/admin/transmissions never existed, so §10.2 is SQLite-only. Setting the variable now refuses to start. - Split TARGET_DB_PATH into TARGET_CONTAINER_DB_PATH and TARGET_HOST_DB_PATH, each used only by its own runner. The path is checked as a non-empty file in the runner's environment before any query, and sqlite3 runs with -readonly, so a wrong path can no longer leave an empty database behind. The legacy variable refuses to start. - Trap SIGPIPE like INT/TERM (teardown, 141, +1 on teardown failure) and ignore it inside teardown, so a closed stdout/stderr cannot cut the restore short. - Route all messages through say/warn: bash 3.2 replays text it failed to write into the next subshell's output, which with a dead stdout put the pubkey and log lines into the remote config-edit script. A dead stream is now sent to /dev/null and its buffer flushed there. - Build remote command lines with printf -v %q instead of $(...). - Correct the RESTART_WAIT_S and second-signal-in-teardown wording. - Tests: a fake target (ssh/docker/curl + argv-logging shims) runs the whole script through success, failure, signal and broken-stream cases. - CI: run shellcheck -x on both scripts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Independent review of the #83 change found no blockers; this addresses its should-fix items and surviving mutants: - §10.1 now requires BOTH a 404 from the detail endpoint and absence from the listing, and a listing that cannot be fetched fails instead of counting as "not listed" (it previously passed with detail=200). - SIGHUP is handled like INT/TERM (teardown, 129, +1 on teardown failure). - Document the WAL requirement of the -readonly open, the inherited SIGPIPE ignore inside teardown, and include the path-check exit code. - Tests: listing 500 and detail-only leak, empty file at the container path, a sqlite3 that echoes the probe token among other output, the remote hex re-check, SIGHUP, and bash 3.2's buffer replay after say on a dead stdout. Optional strace execve evidence per full run on Linux (BLACKLIST_TEST_STRACE_DIR). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Second review pass: dropping HUP from teardown's notice trap survived the suite, although a HUP during the stats wait would then re-enter teardown and cut the restore short. The signal-during-teardown case now sends HUP too. BLACKLIST_TEST_STRACE_DIR must be an existing empty directory and strace must be present, so stale traces cannot mix into a run's checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on (#83) GitHub Actions starts steps with SIGPIPE ignored, which bash cannot trap, so `kill -PIPE $$` did nothing there and the two trap cases saw exit 0/1 instead of 141/142 (the script's documented ignored-on-entry behaviour). Run them through a perl wrapper that resets SIGPIPE to default, as the stream-breaker cases already do. The suite now passes both with SIGPIPE at default and with it ignored on entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nonical pubkey (#83) Follow-up review of PR #87 found three problems: 1. Existing privacy rule removed. Teardown always removed the pubkey, so a node that was already in nodeBlacklist lost its rule. A read-only preflight (remote MODE=check, case-insensitive, pubkey on stdin) now refuses the run with exit 2 before any side effect and before the traps, without printing the pubkey. add now appends and remove drops only the exact canonical value, so order, duplicates and other spellings in nodeBlacklist are restored exactly (jq `unique` used to sort and de-duplicate the whole list; python de-duplicated it too). 2. Vacuous topology pass. /api/topology does not exist; the SPA fallback answered 200 HTML and it was reported clean. The check now uses /api/analytics/topology, waits out warm-up 503s for up to RESTART_WAIT_S, requires HTTP 200 and the TopologyResponse shape, and compares the pubkey fields the server filters (topRepeaters, topPairs A/B, bestPathList, multiObsNodes, perObserverReach rings) lower-cased and whole-line. Anything else fails; nothing is skipped. jq is now required on the runner. 3. Case. TEST_NODE_PUBKEY is validated as before, then lower-cased once; config entry, API paths, pattern files and the SQLite binding all use that canonical value. Tests were written first (123 failures against 5b2891e): pre-blacklisted lower/upper/python3, check unreachable, topology HTML/bad shape/not JSON/ 500/stuck 503/warm-up/null arrays/leak in each part in both cases, an upper-case end-to-end run, and exact restore of an unsorted list with a duplicate and an upper-case entry. The fake API no longer serves /api/topology. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A python3 check that compared against the upper-cased pubkey survived: the only python3 case used an upper-case entry. Mixed-case entries, which only a case-insensitive comparison matches, and a lower-case python3 case now cover both check implementations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eck (#83) Independent review of 41b2e0e/350f6dae found no blockers and these gaps: - An empty or blank 200 body from /api/analytics/topology still printed "topology clean": plain `jq FILE` accepts no input, prints nothing and exits 0. jq now reads the body itself (-n, [inputs]) and requires exactly one JSON document; two concatenated documents fail as well. - The preflight compared lower-cased only, while the server trims and lower-cases (buildBlacklistSet); a padded entry was not recognised. Both remote implementations now trim. - The preflight's error branches were untested. Tests now cover a non-array, object-valued or non-JSON nodeBlacklist/config (exit 1 before any side effect, jq and python3), a missing or null list (runs, comes back as []), padded entries, and topology bodies that are empty, blank, two documents, of the wrong field type or with a non-numeric uniqueNodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SC2016 on the topology jq program ($r/$k are jq variables) and SC2012 on the test's ls -l mode/owner read (portable across macOS and Linux). The CI shellcheck step fails on info-level findings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner
Author
|
Review feedback addressed — head
Verification
Out of scope, reported separately: the server's Master has moved to 🤖 Generated with Claude Code |
adminopenclaw8-sketch
pushed a commit
that referenced
this pull request
Sep 24, 2026
Brings in #86 (Relay Airtime Share), #87 (blacklist QA hardening) and #90 (Reach Rank test stabilisation). None of them touch public/live.js or test-live-multibyte-only-e2e.js. The merge was conflict-free, and its tree equals the verified synthetic merge tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
QA-hardening of
qa/scripts/blacklist-test.shfor the follow-ups found while verifying PR #82 (tracked in issue 83). Only the QA script, its unit tests, the QA plan text and one CI test step change; no production code, schema, privacy, MQTT or runtime behaviour.Original findings → what changed
PK=… bash -s), curl URLs, two grep patterns (9execveper run)IFS= read -r PK), remote jq/python3 get it via the environment (env.PK/os.environ); curl reads its URL from a-K -config on stdin; grep reads mode-600 pattern files (grep -F -f) in the run's mode-700 temp dir. SQL was already on stdin.ADMIN_API_TOKENcalls/api/admin/transmissions?from_node=…, which does not existcmd/server; no new endpoint was added. §10.2 is SQLite-only. SettingADMIN_API_TOKENnow refuses to start (exit 2, value never printed).TARGET_DB_PATHused for both runners; hostsqlite3could create an empty fileTARGET_CONTAINER_DB_PATH(container runner only) andTARGET_HOST_DB_PATH(host runner only). A runner is only a candidate if its own path is set. Before any query the path must be a non-empty regular file in that runner's environment, andsqlite3runs with-readonly— two independent guards against creating a file. LegacyTARGET_DB_PATHrefuses to start (exit 2) instead of being reinterpreted.trap 'teardown 141' PIPE; SIGPIPE is ignored inside teardown. See below.RESTART_WAIT_Swording/api/statspoll; worst case ≈RESTART_WAIT_S + CURL_TIMEOUT + 3. The second-signal note is corrected: an interrupted ssh step →teardown-failed, an interrupted stats poll is just one failed poll.shellcheck -x -P SCRIPTDIRon both scripts (ubuntu-latest ships shellcheck); both are clean.Found during this work
/bin/bash): after a write to a dead stdout fails, bash 3.2 keeps the text buffered and the next subshell flushes it into its output. With stdout broken, the pubkey and log lines ended up inside the remote config-edit script and command line (reproduced; bash 5.2 purges the buffer). Fix: every message goes throughsay/warn, which on failure point the stream at/dev/nulland flush the leftover there; remote command lines are built withprintf -v %qinstead of$(printf %q …).hide ok), pre-existing. Hidden now requires detail 404 AND absent from the listing; a listing that cannot be fetched or grepped fails.Review follow-up (
41b2e0e3,350f6dae,877e0938,ec90ea24)A later independent review of
5b2891eafound three problems. All are fixed test-first (the new tests gave 123 failures against5b2891ea):Existing blacklist rule removed. Teardown always removed
TEST_NODE_PUBKEY, so a node that was already innodeBlacklistlost its privacy rule. Now a read-only preflight (remoteMODE=check, pubkey on ssh stdin) runs before any side effect and before the traps. It compares the way the server does: trimmed and lower-cased, as inbuildBlacklistSet. If the pubkey is already listed in any case or padding, the run refuses with exit 2 and does not print the pubkey. Nothing is restarted, and the config bytes, mode and owner are unchanged. If the check cannot run (ssh down, config not JSON, a non-array list), the run fails with exit 1, also before any side effect.addnow appends andremovedrops only the exact canonical value, so the rest of the list is restored exactly: order, duplicates, other spellings and non-string entries. Before, jquniquesorted and de-duplicated the whole list and python de-duplicated it.Vacuous topology pass.
/api/topologydoes not exist, and the SPA fallback answered 200 HTML, which was reported clean. The check now:/api/analytics/topologyand waits out warm-up 503s for up toRESTART_WAIT_S;TopologyResponseshape, with jq reading it via-n/[inputs];topRepeaters[].pubkey,topPairs[].pubkeyA/B,bestPathList[].pubkey,multiObsNodes[].pubkeyandperObserverReach{}.rings[].nodes[].pubkey, extracted, lower-cased and matched whole-line against the canonical pubkey.HTML, a wrong shape, a wrong field type, non-JSON, an empty or blank body, two documents, 500, a 503 that never ends and curl failures all fail; nothing is skipped. Null arrays, which the server produces after filtering, are valid.
jqis now required on the runner.Case.
TEST_NODE_PUBKEYis validated as before, then lower-cased once (trreads it on stdin). The config entry, API paths, pattern files and the SQLite binding all use that canonical value. An end-to-end test with upper-case input against lower-case API and DB data passes, and removing the normalisation fails it.The fake API no longer serves
/api/topology; it serves the real route with the real response shape and can leak the pubkey into each part.Env / path contract
TARGET_DB_PATH(one path for container and host)TARGET_CONTAINER_DB_PATH— path insideTARGET_CONTAINER, used only bydocker exec … sqlite3TARGET_HOST_DB_PATH— path onTARGET_SSH_HOST, used only by the hostsqlite3ADMIN_API_TOKEN(dead API path)TARGET_DB_PATH— removed — refuses to start, names the replacementsTEST_NODE_PUBKEYused as givennodeBlacklist→ removed by teardownjqrequired on the runner (topology parsing)Order: container runner (if its path is set and its sqlite3 passes the bind probe) → host runner (if its path is set). No path is ever tried in the other environment; a wrong path fails as
retain-failednaming the variable. The only in-repo caller is the QA plan text, updated here.-readonlyneeds the WAL files of a running app; §10.2 runs after/api/statshas shown the restarted app is up (verified on a live WAL database below).Signals / SIGPIPE
Exit = failures (0 = pass); 2 = refused to start (bad/removed settings, or the pubkey is already blacklisted); interrupted runs tear down and exit 130 (INT), 143 (TERM), 129 (HUP), 141 (PIPE), +1 if teardown failed. Without a PIPE handler bash still runs the EXIT trap but re-raises the signal, so the status was always 141 whatever teardown found, and teardown's next write to the dead stream cut the restore short. Inside teardown SIGPIPE is ignored (children inherit the ignore and see EPIPE). A shell started with SIGPIPE already ignored cannot trap it; it then runs to its normal status with dead streams sent to
/dev/null.argv evidence
ssh/docker/curl, every other command behind an argv-logging PATH shim, remote side included) runs the unmodified script end to end; on Linux each full run is additionally wrapped instrace -f -e execve(BLACKLIST_TEST_STRACE_DIR). Atec90ea24: 73 traced runs, 6673execve, 0 containing the pubkey (either case), SQL or token.strace -f -e execveon the runner and on the target's sshd: 1660execve(379 runner, 1281 target); 0 hits for pubkey (lower/upper),SELECT,from_pubkey, token or/api/nodesURL; positive controls present (ssh 71, curl 82, grep 16, jq 27, sqlite3 9, docker 29). Remote edit argv is exactlybash -c "CFG=/srv/corescope-host/data/config.json MODE=add bash -s".Verification
bash -nboth scripts;git diff --check; YAML parse ofdeploy.yml;go test -run 'ForkGuard|Workflow|ReleaseFastPath'incmd/server(5 pass).bash qa/scripts/test-blacklist-sql.shatec90ea24: macOS bash 3.2 — 849 passed, 0 failed; Linux (ubuntu 24.04, bash 5.2, sqlite 3.45, jq 1.7) — 907 passed, 0 failed (incl. strace asserts); both again with SIGPIPE ignored on entry (849/0, 897/0). Branch base is6334c427. Master has since moved toe51272d9(PR test(server): replace distance lock timing threshold with a deterministic check #79 and PR feat(analytics): split advert relay airtime by route #86). The only overlap isdeploy.yml: feat(analytics): split advert relay airtime by route #86 adds one JS test line and our two steps are untouched. The merge is clean, the YAML parses,TestForkGuard*pass on the merge result, and the suite passes on it (849/0). Neither PR touches the topology route, the blacklist or/api/nodes.-x: clean (isolated Linux container).5b2891ea): GitHub Actions starts steps with SIGPIPE ignored, which bash cannot trap, so the twokill -PIPEtrap cases saw 0/1 instead of 141/142 — the script's documented ignored-on-entry behaviour, not a script fault. Those cases now reset SIGPIPE to default first (as the stream-breaker cases already did); the suite passes with SIGPIPE at default and ignored on entry, on macOS and Linux, and all 31 mutants are still killed with it ignored. Operational note: a runner that starts the script with SIGPIPE ignored gets the EPIPE path (run completes with its real status, dead streams go to/dev/null), not the 141 path.ec90ea24, all killed on macOS; 52/54 on Linux. Added for the review follow-up (M32–M54): preflight skipped, moved after the traps, case-sensitive (jq, python3), no trim (jq, python3), printing the pubkey, exiting 0; unreadable config / non-list / parse error / missing list treated as "not blacklisted";addsorting and de-duplicating again; topology on the old route, non-200 skipped, shape check removed, text grep instead of the field check,perObserverReachorpubkeyBnot checked, case-sensitive compare, no 503 wait, empty body accepted; normalisation removed. The earlier set: They include pubkey back in ssh argv / remote jq argv / curl URL / grep argv, host runner using the container path and vice versa, legacyTARGET_DB_PATHfallback, sqlite allowed to create a file (both guards, and each guard alone), empty-file size check dropped, admin token accepted / admin API path reintroduced, SIGPIPE swallowed / reported as pass / untrapped, teardown not PIPE-safe, TERM → 0, teardown skipped, teardown failure not counted, SQL interpolated, query error → 0, grep error → absent, probe substring match, remote hex check dropped, hide OR-logic, list error → absent, HUP untrapped, HUP not handled inside teardown. The two Linux survivors (plainechoinstead ofsay/warn; no buffer flush) only matter on bash 3.2, where they are killed — bash 5 has no replay bug.Full isolated SSH/Docker QA (demo, own prefix)
App image built from this branch's
Dockerfile(nosqlite3, as in production) plus a test variant withsqlite;--internalnetwork, no published ports, outbound verified blocked;DISABLE_MOSQUITTO/DISABLE_CADDY; synthetic config, database and pubkeys. The script ran in a runner container over real OpenSSH to disposable sshd "target hosts" that drive the app via the Docker socket. The target sees the data at/srv/corescope-host/data, the app at/app/data— different container and host paths. Settings reached the runner through an env file, not argv.from_pubkey)TARGET_DB_PATH/ADMIN_API_TOKEN→ refuse before any side effectAfter every run: config semantically identical with the same mode/owner, blacklist
[], synthetic transmissions unchanged (digest), decoy/legacy DBs byte-identical, no new files in the data dirs, app running and the node back at/api/nodes/<pk>= 200, exit as expected. The-readonlyopen worked against the app's live WAL-mode database in both runners. #82's preserved resources were not touched.Review follow-up on demo (same isolated setup, final script
877e0938/ec90ea24)/api/analytics/topology→200 application/jsonwith allTopologyResponsekeys;/api/topology→200 text/html(the SPA fallback that used to pass).TEST_NODE_PUBKEYTEST_NODE_PUBKEY, lower-case API and DBnodeBlacklist[], otherwise identicalEarlier rounds on
41b2e0e3covered the same plus SIGTERM, a dead stdout and a query error (all as expected). Across all 19 follow-up runs: 2252execve(runner and target sshd), with 0 hits for the pubkey (either case),SELECT,from_pubkey, the token,/api/nodesor/api/analytics.Independent review
A separate reviewer agent (not the author) checked quoting, stdin/file transport, argv evidence, the path contract, empty-file risk, signals/SIGPIPE/teardown, classification, mutation strength, #82's properties and scope: no blockers. Its should-fix items (the §10.1 list-failure pass; untested
-readonlyon WAL) and nits are addressed in8a7bed26/ the demo run; its surviving mutants now have tests. A second pass on8a7bed26found no blockers and one test gap (HUP during teardown), closed inf97d4949;5b2891eafixes the CI-only SIGPIPE harness issue. Both are test-only; the script the demo exercised is unchanged.For the review follow-up, a new independent reviewer checked the three fixes specifically: no blockers. Its should-fix items are fixed in
877e0938and re-verified by the same reviewer (no blockers, all its mutants killed):Limitations
config.jsonthrough jq/python (pretty-printed), as before; content, mode and owner are restored, bytes/formatting are not.filterBlacklistedFromTopology(cmd/server/routes.go) type-asserts typed slices such as[]TopRepeater, butcomputeAnalyticsTopologybuilds[]map[string]interface{}, so the server's topology blacklist filter does nothing on real data, and no server test covers it. On a target where a blacklisted node appears in topology, the corrected check will therefore reporthide-failed, as it should. This is a production fix for a separate PR.observers[].id, theperObserverReachkeys,observer_id) and hop prefixes are deliberately not asserted: the server does not filter observers, and hop prefixes would collide.nodeBlacklistcomes back as[](the same to the server). There is a small race if an operator adds the same pubkey between the check and the add.docker exec … sqlite3 …) is evidenced through the docker CLI's argv on the target, not by tracing runc.-readonlyneeds the app's WAL files present (app running); with the app down §10.2 fails loudly (never a false pass).Workflow safety
The workflow diff is the renamed QA unit-test step and one added
shellcheckstep in thego-testjob. Noif:conditions, fork guards, GHCR, release, staging-deploy or badge steps change (TestForkGuard*pass). No staging, production or upstream system was contacted.🤖 Generated with Claude Code