From 42139193efeb05ca986bc8538b515d91b5df463d Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 30 Sep 2026 18:35:13 +0800 Subject: [PATCH] fix(deploy): fail closed before non-production call diagnostics --- deploys/test/README.md | 8 +- deploys/test/nonprod-call-evidence.sh | 47 +++++++++-- .../saas-dispatcher-implementation.md | 2 + internal/config/nonprod_call_gate_test.go | 82 +++++++++++++++++++ 4 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 internal/config/nonprod_call_gate_test.go diff --git a/deploys/test/README.md b/deploys/test/README.md index 5199183..99044c3 100644 --- a/deploys/test/README.md +++ b/deploys/test/README.md @@ -21,7 +21,13 @@ non-production `mock`, `mixed` and `real` call checks. It runs on a validation host with native Asterisk and required diagnostics; it is not an Asterisk or Agent replacement and is not containerized. Run it explicitly with the current call authorization and the approved target/trunk. It refuses production mode -and fails closed when its prerequisites are missing. +and fails closed when its prerequisites are missing. Before any dial attempt it +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. 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 4076921..f23254a 100755 --- a/deploys/test/nonprod-call-evidence.sh +++ b/deploys/test/nonprod-call-evidence.sh @@ -60,7 +60,11 @@ while (($#)); do esac done -[[ "$environment" != production ]] || { echo 'production requires the separate production gate' >&2; exit 1; } +case "$environment" in + development|mock|mixed|real) ;; + production) echo 'production requires the separate production gate' >&2; exit 1 ;; + *) echo 'invalid non-production environment' >&2; exit 1 ;; +esac [[ "$call_id" =~ ^[A-Za-z0-9._-]+$ ]] || { echo 'invalid call id' >&2; exit 1; } [[ "$trunk" =~ ^(provider-primary|provider-second|provider-third|trunk-[A-Za-z0-9._-]+)$ ]] || { echo 'trunk is not an approved non-production trunk id' >&2; exit 1; } [[ "$target" =~ ^(15003164745|15830461047)$ ]] || { echo 'target is outside the approved outbound whitelist' >&2; exit 1; } @@ -68,6 +72,15 @@ done [[ ${#call_command[@]} -gt 0 ]] || { echo 'call command is required after --' >&2; exit 1; } [[ "$interface" =~ ^[A-Za-z0-9_.:-]+$ ]] || { echo 'invalid capture interface' >&2; exit 1; } [[ "$sip_port" =~ ^[0-9]+$ && "$rtp_start" =~ ^[0-9]+$ && "$rtp_end" =~ ^[0-9]+$ ]] || { echo 'invalid port' >&2; exit 1; } +require_call_window() { + local shanghai_hm + shanghai_hm="$(TZ=Asia/Shanghai date +%H%M)" || { echo 'Asia/Shanghai clock unavailable; fail-closed' >&2; exit 1; } + if [[ ! "$shanghai_hm" =~ ^[0-9]{4}$ || "$shanghai_hm" < "0900" || "$shanghai_hm" > "1959" ]]; then + echo 'outside Asia/Shanghai 09:00-20:00; fail-closed' >&2 + exit 1 + fi +} +require_call_window if [[ "$interface" == any ]]; then default_interface="$(ip route show default 2>/dev/null | awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}')" [[ -n "$default_interface" ]] && interface="$default_interface" @@ -107,16 +120,39 @@ redact() { sed -E 's/(password|secret|token|authorization|api[_-]?key)[^[:space:]]*/\1=/Ig' } -systemctl is-enabled asterisk.service >"$evidence_dir/asterisk-enabled.txt" 2>&1 || true -systemctl is-active asterisk.service >"$evidence_dir/asterisk-active.txt" 2>&1 || true +if ! systemctl is-enabled asterisk.service >"$evidence_dir/asterisk-enabled.txt" 2>&1 || + ! grep -qx enabled "$evidence_dir/asterisk-enabled.txt"; then + echo 'Asterisk service must be enabled and active; fail-closed' >&2 + exit 1 +fi +if ! systemctl is-active asterisk.service >"$evidence_dir/asterisk-active.txt" 2>&1 || + ! grep -qx active "$evidence_dir/asterisk-active.txt"; then + echo 'Asterisk service must be enabled and active; fail-closed' >&2 + exit 1 +fi uname -a >"$evidence_dir/uname.txt" cat /etc/os-release >"$evidence_dir/os-release.txt" ip -brief address >"$evidence_dir/ip-address.txt" ss -lunp >"$evidence_dir/udp-listeners.txt" 2>&1 || ss -lun >"$evidence_dir/udp-listeners.txt" +"$asterisk_bin" -rx "module show like res_ari.so" 2>&1 | redact >"$evidence_dir/ari-module-status.txt" +"$asterisk_bin" -rx "http show status" 2>&1 | redact >"$evidence_dir/ari-http-status.txt" +if ! grep -Eq 'res_ari\.so.*Running' "$evidence_dir/ari-module-status.txt" || + ! grep -Fq 'Server Enabled and Bound' "$evidence_dir/ari-http-status.txt" || + ! grep -Fq '/ari/' "$evidence_dir/ari-http-status.txt"; then + echo 'ARI module or HTTP route unavailable; fail-closed' >&2 + exit 1 +fi "$asterisk_bin" -rx "pjsip show endpoint $trunk" 2>&1 | redact >"$evidence_dir/pjsip-endpoint.txt" +if ! grep -Eq 'Endpoint:[[:space:]]*' "$evidence_dir/pjsip-endpoint.txt" || ! grep -Fq "$trunk" "$evidence_dir/pjsip-endpoint.txt"; then + echo 'PJSIP endpoint unavailable; fail-closed' >&2 + exit 1 +fi "$asterisk_bin" -rx "pjsip show contacts" 2>&1 | redact >"$evidence_dir/pjsip-contacts-before.txt" "$asterisk_bin" -rx "core show channels verbose" 2>&1 | redact >"$evidence_dir/channels-before.txt" -sha256sum /opt/sip-go-agent/current/sip-go-agent /etc/sip-go-agent/artifacts/*.json /etc/sip-go-agent/ai/*.json >"$evidence_dir/installed-sha256.txt" 2>&1 || true +if ! sha256sum /opt/sip-go-agent/current/sip-go-agent /etc/sip-go-agent/artifacts/*.json /etc/sip-go-agent/ai/*.json >"$evidence_dir/installed-sha256.txt" 2>&1; then + echo 'installed package/config SHA-256 unavailable; fail-closed' >&2 + exit 1 +fi logger_enabled=0 capture_pid="" @@ -243,7 +279,7 @@ reserve_attempt() { return fi local today count legacy_count metadata - today="$(date -u +%F)" + today="$(TZ=Asia/Shanghai date +%F)" install -d -m 0700 "$(dirname "$attempt_ledger")" touch "$attempt_ledger" exec 9>>"$attempt_ledger.lock" @@ -295,6 +331,7 @@ if ((preflight_only)); then exit 0 fi +require_call_window call_status=0 set +e runuser -u "$run_as" -- "${call_command[@]}" >"$evidence_dir/call-output.private" 2>&1 diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index 5630b64..621ac9c 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -159,3 +159,5 @@ P01–P07 的项目内隔离证据见上;P07 唯一当前入口、全仓残留 ## P08 本地验收进度 - 十分钟 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 签收。 diff --git a/internal/config/nonprod_call_gate_test.go b/internal/config/nonprod_call_gate_test.go new file mode 100644 index 0000000..2d12bfa --- /dev/null +++ b/internal/config/nonprod_call_gate_test.go @@ -0,0 +1,82 @@ +package config + +import ( + "context" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNonprodCallEvidenceFailsBeforeDialWithoutRequiredGates(t *testing.T) { + cases := []struct { + name, hour, environment, enabled, active, ari, endpoint, want string + beforeEvidence bool + }{ + {name: "before real window", hour: "0859", environment: "real", enabled: "enabled", active: "active", ari: "ready", want: "outside Asia/Shanghai 09:00-20:00", beforeEvidence: true}, + {name: "at real window end", hour: "2000", environment: "real", enabled: "enabled", active: "active", ari: "ready", want: "outside Asia/Shanghai 09:00-20:00", beforeEvidence: true}, + {name: "invalid local clock", hour: "error", environment: "real", enabled: "enabled", active: "active", ari: "ready", want: "Asia/Shanghai clock unavailable", beforeEvidence: true}, + {name: "window opens at nine", hour: "0900", environment: "real", enabled: "disabled", active: "active", ari: "ready", want: "Asterisk service must be enabled and active"}, + {name: "last permitted minute", hour: "1959", environment: "real", enabled: "disabled", active: "active", ari: "ready", want: "Asterisk service must be enabled and active"}, + {name: "invalid nonproduction environment", hour: "1000", environment: "Production", enabled: "enabled", active: "active", ari: "ready", want: "invalid non-production environment", beforeEvidence: true}, + {name: "asterisk not enabled", hour: "1000", environment: "real", enabled: "disabled", active: "active", ari: "ready", want: "Asterisk service must be enabled and active"}, + {name: "asterisk not active", hour: "1000", environment: "real", enabled: "enabled", active: "inactive", ari: "ready", want: "Asterisk service must be enabled and active"}, + {name: "ARI module not loaded", hour: "1000", environment: "real", enabled: "enabled", active: "active", ari: "module-absent", want: "ARI module or HTTP route unavailable"}, + {name: "ARI HTTP route not enabled", hour: "1000", environment: "real", enabled: "enabled", active: "active", ari: "http-absent", want: "ARI module or HTTP route unavailable"}, + {name: "PJSIP endpoint missing", hour: "1000", environment: "real", enabled: "enabled", active: "active", ari: "ready", endpoint: "missing", want: "PJSIP endpoint unavailable"}, + {name: "missing installed artifact hashes", hour: "1000", environment: "real", enabled: "enabled", active: "active", ari: "ready", want: "installed package/config SHA-256 unavailable"}, + } + for _, tc := range cases { + t.Run(tc.name, func(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 if [ "+tc.hour+" = error ]; then exit 1; fi; echo "+tc.hour+"; else exec /usr/bin/date \"$@\"; fi\n") + write("ip", "echo 'lo UNKNOWN 127.0.0.1/8'\n") + write("ss", "echo 'udp 127.0.0.1:5060'\n") + write("systemctl", "case \"$1\" in is-enabled) echo \"$TEST_ENABLED\";; is-active) echo \"$TEST_ACTIVE\";; *) exit 1;; esac\n") + asterisk := write("asterisk", "case \"$2\" in 'module show like res_ari.so') if [ \"$TEST_ARI\" = module-absent ]; then echo '0 modules loaded'; else echo 'res_ari.so Asterisk REST Interface 0 Running'; fi;; 'http show status') if [ \"$TEST_ARI\" = http-absent ]; then echo 'Server Disabled'; else echo 'Server Enabled and Bound to 127.0.0.1:8088'; echo '/ari/...'; fi;; 'pjsip show endpoint '*) if [ \"$TEST_ENDPOINT\" = missing ]; then echo 'Unable to find object'; else echo 'Endpoint: provider-primary'; fi;; *) echo 'mock Asterisk status';; esac\n") + tcpdump := write("tcpdump", "case \" $* \" in *' -c 1 '*) exit 124;; *) exit 91;; esac\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(), 5*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "bash", "../../deploys/test/nonprod-call-evidence.sh", + "--environment", tc.environment, "--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"), "--", "/bin/true") + command.Env = append(os.Environ(), "PATH="+tools+":"+os.Getenv("PATH"), "ASTERISK_BIN="+asterisk, "TCPDUMP_BIN="+tcpdump, + "TEST_DIAL_MARKER="+marker, "TEST_ENABLED="+tc.enabled, "TEST_ACTIVE="+tc.active, "TEST_ARI="+tc.ari, "TEST_ENDPOINT="+tc.endpoint) + output, err := command.CombinedOutput() + if ctx.Err() != nil { + t.Fatalf("isolated diagnostic gate hung: %v", ctx.Err()) + } + if err == nil || !strings.Contains(string(output), tc.want) { + t.Fatalf("required gate was skipped or failed for another reason: err=%v want=%q output=%s", err, tc.want, output) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("diagnostic script invoked the call command: marker err=%v", err) + } + if tc.beforeEvidence { + if _, err := os.Stat(evidence); !os.IsNotExist(err) { + t.Fatalf("preflight reject wrote host evidence or opened capture: err=%v", err) + } + } + }) + } +}