From 8c5c85e4394899f90a386e4bb1da301af6f242e6 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 30 Sep 2026 18:45:24 +0800 Subject: [PATCH] fix(deploy): fail closed on incomplete post-call evidence --- deploys/test/README.md | 7 +- deploys/test/nonprod-call-evidence.sh | 46 ++++++++++--- .../saas-dispatcher-implementation.md | 2 + .../config/nonprod_evidence_cleanup_test.go | 66 +++++++++++++++++++ 4 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 internal/config/nonprod_evidence_cleanup_test.go diff --git a/deploys/test/README.md b/deploys/test/README.md index 99044c3..b6856c5 100644 --- a/deploys/test/README.md +++ b/deploys/test/README.md @@ -26,8 +26,11 @@ checks the Asia/Shanghai 09:00–20:00 window twice (09:00 included, 20:00 excluded), the exact `enabled` + `active` Asterisk systemd state, the running ARI module and HTTP `/ari/` route, the selected PJSIP endpoint, and SHA-256 of the installed binary and configuration. Missing facts fail the validation; -`--preflight-only` never authorizes a call. Isolated tests replace host tools -with fakes: they do not prove a real host or supplier is ready. +`--preflight-only` never authorizes a call. After capture, missing capture or +recording SHA-256, Asterisk journal, SIP summary, logger shutdown, timestamp, or +restricted evidence ownership is recorded in `diagnostic-errors.txt` and fails +the check; an already failed call keeps its nonzero result. Isolated tests +replace host tools with fakes: they do not prove a real host or supplier is ready. The offline OSS environment file is a fixture for isolated tests only. It contains no real credentials or production approval. The former diff --git a/deploys/test/nonprod-call-evidence.sh b/deploys/test/nonprod-call-evidence.sh index f23254a..bf25343 100755 --- a/deploys/test/nonprod-call-evidence.sh +++ b/deploys/test/nonprod-call-evidence.sh @@ -259,19 +259,49 @@ stop_capture() { capture_pid="" } cleanup() { + local call_exit=$? evidence_failed=0 + trap - EXIT set +e stop_capture - if ((logger_enabled)); then - "$asterisk_bin" -rx "pjsip set logger off" >"$evidence_dir/pjsip-logger-off.txt" 2>&1 || true + if ((logger_enabled)) && ! "$asterisk_bin" -rx "pjsip set logger off" >"$evidence_dir/pjsip-logger-off.txt" 2>&1; then + printf 'PJSIP logger stop failed\n' >>"$evidence_dir/diagnostic-errors.txt" + evidence_failed=1 + fi + if ! date -u +%Y-%m-%dT%H:%M:%SZ >"$evidence_dir/ended-at.txt"; then + printf 'end timestamp unavailable\n' >>"$evidence_dir/diagnostic-errors.txt" + evidence_failed=1 fi - date -u +%Y-%m-%dT%H:%M:%SZ >"$evidence_dir/ended-at.txt" if [[ -f "$evidence_dir/capture.pcap" ]]; then - sha256sum "$evidence_dir/capture.pcap" >"$evidence_dir/capture.pcap.sha256" || true + if ! sha256sum "$evidence_dir/capture.pcap" >"$evidence_dir/capture.pcap.sha256"; then + printf 'capture SHA-256 unavailable\n' >>"$evidence_dir/diagnostic-errors.txt" + evidence_failed=1 + fi + elif ((logger_enabled)); then + printf 'capture file missing\n' >>"$evidence_dir/diagnostic-errors.txt" + evidence_failed=1 fi - find "$recording_dir" -maxdepth 1 -type f -newer "$evidence_dir/recording-start.marker" -print0 | xargs -0r sha256sum >"$evidence_dir/recordings.sha256" || true - journalctl -u asterisk.service --since "$started_at" --no-pager 2>/dev/null | redact >"$evidence_dir/asterisk-journal.txt" || true - write_sip_summary || printf '{"error":"sip summary unavailable"}\n' >"$evidence_dir/sip-summary.json" - chown -R "$run_as:$run_as" "$evidence_dir" 2>/dev/null || true + if ! find "$recording_dir" -maxdepth 1 -type f -newer "$evidence_dir/recording-start.marker" -print0 | xargs -0r sha256sum >"$evidence_dir/recordings.sha256"; then + printf 'recording SHA-256 unavailable\n' >>"$evidence_dir/diagnostic-errors.txt" + evidence_failed=1 + fi + if ! journalctl -u asterisk.service --since "$started_at" --no-pager 2>/dev/null | redact >"$evidence_dir/asterisk-journal.txt"; then + printf 'Asterisk journal unavailable\n' >>"$evidence_dir/diagnostic-errors.txt" + evidence_failed=1 + fi + if ! write_sip_summary; then + printf '{"error":"sip summary unavailable"}\n' >"$evidence_dir/sip-summary.json" + printf 'SIP summary unavailable\n' >>"$evidence_dir/diagnostic-errors.txt" + evidence_failed=1 + fi + if ! chown -R "$run_as:$run_as" "$evidence_dir" 2>/dev/null; then + printf 'restricted evidence ownership update failed\n' >>"$evidence_dir/diagnostic-errors.txt" + evidence_failed=1 + fi + if ((evidence_failed)); then + echo 'required post-call evidence unavailable; fail-closed' >&2 + if ((call_exit == 0)); then exit 1; fi + fi + exit "$call_exit" } trap cleanup EXIT reserve_attempt() { diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index 621ac9c..9548fe2 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -161,3 +161,5 @@ P01–P07 的项目内隔离证据见上;P07 唯一当前入口、全仓残留 - 十分钟 Agent 会话续期:审查发现 Dispatcher 之前只在启动时激活一次,约十分钟后 Agent 和 Dispatcher 均拒绝过期会话,长时间运行时无法再执行/上报。新增从真实会话到期时刻计算的提前五分钟续期;只允许同一个已审批 Agent boot、Cell 与 Dispatcher epoch 生成更高代际,激活响应、到期前/状态探测后的有效期及报告中的代际均须复核。续期失败立即关闭新准入、取消服务并以 Agent ID/代际的脱敏错误说明原因,不接纳未知新 boot、不自动重拨。TDD 先复现没有 `Renew`/循环、状态探测期间过期仍被重新激活,再以 bufconn 真实双端会话、可控时钟及重复 race 测试证明原会话过期后新会话仍可操作、旧代际被拒绝、换 boot/过期/失败不续;正常取消不再发起激活。`go test -race ./internal/dispatcher ./cmd/sip-go-agent -count=1`、`make check`(三项隔离 MQ 明确 PASS)、`make coverage`(全部非生成手写代码语句覆盖率 **71.8%**)、`make release-check-local` 通过。录音上报与续期切换竞争的故障证据和非生产诊断脚本门禁仍待审查,不将此条视为 P08 完成或真实主机验证。 - 非生产呼叫诊断**前置门禁**:审查发现原脚本允许绕过 Asia/Shanghai 真实窗口、将 Asterisk `enabled/active` 状态和已安装制品 SHA-256 错误用 `|| true` 吞掉,也未检查 ARI module/HTTP `/ari/` 与所选 PJSIP endpoint。新增 `internal/config/nonprod_call_gate_test.go`:先以纯本机假工具复现缺失门禁,再覆盖时段 `08:59`/`09:00`/`19:59`/`20:00`、错误时钟、无效环境、systemd 两种失败、ARI 模块/HTTP route、缺失 endpoint/哈希,12 个故障/边界均在**假 `runuser` 被调用之前**拒绝;时间门禁在准备证据前及实际执行前各查一次,时段左闭右开,状态和 SHA 失败均显式错误。`bash -n deploys/test/nonprod-call-evidence.sh` 与定向 `go test -race ./internal/config` 通过。这是隔离负例,既无真实主机状态,也没有发起 SIP 呼叫;拨号后证据收集的故障处置及部署/SSH/磁盘诊断仍待核验,不冒充完整 P08 签收。 + +- 非生产证据**结束收集门禁**:原脚本在 `EXIT` 收尾时使用 `|| true` 掩盖抓包 SHA-256 等必需事实缺失,隔离 `--preflight-only` 试验先复现“合成抓包 hash 失败却返回成功”;新增 `TestNonprodPreflightRejectsIncompleteCapturedEvidence`,使用本机假 Asterisk/tcpdump/systemctl/sha256sum 和临时目录,仅写合成抓包、**不调用假拨号命令**。收尾现在保存原调用失败码,并单独核验 PJSIP logger 关闭、结束时间、抓包及录音 hash、Asterisk journal、SIP 摘要和受限目录归属;任一失败留 `diagnostic-errors.txt` 的脱敏原因,原本成功的前置检查须返回失败。`bash -n` 和定向 `go test -race ./internal/config` 经预期红→绿;受限临时目录保留了失败时的合成 pcap 与 hash 错误事实。尚未有真实主机、真实 RTP、SSH/EIP 与供应商证据,绝不据此声称非生产现场验证已通过。 diff --git a/internal/config/nonprod_evidence_cleanup_test.go b/internal/config/nonprod_evidence_cleanup_test.go new file mode 100644 index 0000000..982ab18 --- /dev/null +++ b/internal/config/nonprod_evidence_cleanup_test.go @@ -0,0 +1,66 @@ +package config + +import ( + "context" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNonprodPreflightRejectsIncompleteCapturedEvidence(t *testing.T) { + tools := t.TempDir() + write := func(name, body string) string { + t.Helper() + path := filepath.Join(tools, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0700); err != nil { + t.Fatal(err) + } + return path + } + write("id", "if [ \"$1\" = -u ]; then echo 0; else exec /usr/bin/id \"$@\"; fi\n") + write("date", "if [ \"${TZ-}\" = Asia/Shanghai ] && [ \"$1\" = +%H%M ]; then echo 1000; else exec /usr/bin/date \"$@\"; fi\n") + write("systemctl", "case \"$1\" in is-enabled) echo enabled;; is-active) echo active;; *) exit 1;; esac\n") + write("ip", "echo 'lo UNKNOWN 127.0.0.1/8'\n") + write("ss", "echo 'udp 127.0.0.1:5060'\n") + write("journalctl", "exit 0\n") + write("chown", "exit 0\n") + asterisk := write("asterisk", "case \"$2\" in 'module show like res_ari.so') echo 'res_ari.so Asterisk REST Interface 0 Running';; 'http show status') echo 'Server Enabled and Bound to 127.0.0.1:8088'; echo '/ari/...';; 'pjsip show endpoint '*) echo 'Endpoint: provider-primary';; *) echo 'mock Asterisk status';; esac\n") + tcpdump := write("tcpdump", "case \" $* \" in *' -c 1 '*) exit 124;; esac\nprev=''\nfor arg in \"$@\"; do if [ \"$prev\" = -w ]; then printf 'synthetic packet bytes' >\"$arg\"; fi; prev=\"$arg\"; done\necho '1 packets captured'\nsleep 3\n") + write("sha256sum", "if [ \"$1\" = /opt/sip-go-agent/current/sip-go-agent ]; then echo 'synthetic installed hash'; exit 0; fi\nif [ \"$1\" = \"$TEST_EVIDENCE/capture.pcap\" ]; then echo 'synthetic capture hash failure' >&2; exit 1; fi\nexit 0\n") + marker := filepath.Join(tools, "DIALED") + write("runuser", "touch \"$TEST_DIAL_MARKER\"; exit 88\n") + currentUser, err := user.Current() + if err != nil { + t.Fatal(err) + } + evidence := filepath.Join(t.TempDir(), "evidence") + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "bash", "../../deploys/test/nonprod-call-evidence.sh", + "--environment", "mock", "--trunk", "provider-primary", "--target", "15003164745", + "--interface", "lo", "--run-as", currentUser.Username, "--recording-dir", filepath.Join(tools, "recordings"), + "--evidence-dir", evidence, "--attempt-ledger", filepath.Join(tools, "attempts.tsv"), "--preflight-only", "--", "/bin/true") + command.Env = append(os.Environ(), "PATH="+tools+":"+os.Getenv("PATH"), "ASTERISK_BIN="+asterisk, "TCPDUMP_BIN="+tcpdump, + "TEST_EVIDENCE="+evidence, "TEST_DIAL_MARKER="+marker) + output, err := command.CombinedOutput() + if ctx.Err() != nil { + t.Fatalf("isolated preflight timed out: %v", ctx.Err()) + } + if err == nil || !strings.Contains(string(output), "required post-call evidence unavailable") { + t.Fatalf("missing capture checksum did not fail the preflight: err=%v output=%s", err, output) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("preflight invoked the call command: marker err=%v", err) + } + if _, err := os.Stat(filepath.Join(evidence, "capture.pcap")); err != nil { + t.Fatalf("synthetic evidence was not preserved after collection failed: %v", err) + } + failure, err := os.ReadFile(filepath.Join(evidence, "diagnostic-errors.txt")) + if err != nil || !strings.Contains(string(failure), "capture SHA-256 unavailable") { + t.Fatalf("missing checksum cause was not preserved: err=%v fact=%s", err, failure) + } +}