From fce01d1be619f2a6b00f74446256ca013b7df1c2 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 4 Oct 2026 12:16:54 +0800 Subject: [PATCH] fix(test): inspect user Asterisk service before nonproduction calls --- deploys/test/README.md | 10 +++ deploys/test/nonprod-call-evidence.sh | 56 +++++++++++--- .../config/nonprod_evidence_cleanup_test.go | 76 +++++++++++++++++++ 3 files changed, 130 insertions(+), 12 deletions(-) diff --git a/deploys/test/README.md b/deploys/test/README.md index b6856c5..40a041e 100644 --- a/deploys/test/README.md +++ b/deploys/test/README.md @@ -32,6 +32,16 @@ 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. +For the **nonproduction user-level Asterisk service only**, pass +`--asterisk-scope user` and explicitly set `ASTERISK_BIN` to its installed +native binary and `ASTERISK_CONFIG` to its user-owned `asterisk.conf`; the +native library directory defaults to the binary's sibling `../lib` and may be +set via `ASTERISK_LIBRARY_PATH`. This mode checks `go-sip-asterisk.service` +through `systemctl --user`, runs the CLI with the exact config and collects +the user journal. Missing settings or status fail closed; it neither skips +the installed-artifact checksum/capture requirements nor proves reboot +persistence when lingering is disabled. The default remains system scope. + The offline OSS environment file is a fixture for isolated tests only. It contains no real credentials or production approval. The former `ai-dental-meiba-v1.json` is archived at diff --git a/deploys/test/nonprod-call-evidence.sh b/deploys/test/nonprod-call-evidence.sh index bf25343..08fa4b3 100755 --- a/deploys/test/nonprod-call-evidence.sh +++ b/deploys/test/nonprod-call-evidence.sh @@ -12,6 +12,7 @@ Options: --evidence-dir DIR Evidence root (default: /var/lib/sip-go-agent/evidence/). --interface IFACE Capture interface (default: default-route interface; any fallback). --run-as USER Run COMMAND as this non-root user (default: rogee). + --asterisk-scope SCOPE system (default) or explicitly configured user service. --sip-port PORT SIP UDP port (default: 5060). --rtp-start PORT RTP range start (default: 10000). --rtp-end PORT RTP range end (default: 10800). @@ -29,6 +30,7 @@ evidence_dir="" recording_dir="/var/lib/sip-go-agent/recordings" interface="any" run_as="rogee" +asterisk_scope="system" sip_port=5060 rtp_start=10000 rtp_end=10800 @@ -47,6 +49,7 @@ while (($#)); do --recording-dir) [[ $# -ge 2 ]] || usage; recording_dir=$2; shift 2 ;; --interface) [[ $# -ge 2 ]] || usage; interface=$2; shift 2 ;; --run-as) [[ $# -ge 2 ]] || usage; run_as=$2; shift 2 ;; + --asterisk-scope) [[ $# -ge 2 ]] || usage; asterisk_scope=$2; shift 2 ;; --sip-port) [[ $# -ge 2 ]] || usage; sip_port=$2; shift 2 ;; --rtp-start) [[ $# -ge 2 ]] || usage; rtp_start=$2; shift 2 ;; --rtp-end) [[ $# -ge 2 ]] || usage; rtp_end=$2; shift 2 ;; @@ -65,6 +68,7 @@ case "$environment" in production) echo 'production requires the separate production gate' >&2; exit 1 ;; *) echo 'invalid non-production environment' >&2; exit 1 ;; esac +[[ "$asterisk_scope" == system || "$asterisk_scope" == user ]] || { echo 'invalid Asterisk service scope' >&2; exit 1; } [[ "$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; } @@ -96,6 +100,12 @@ install -d -o "$run_as" -g "$run_as" -m 0700 "$recording_dir" touch "$evidence_dir/recording-start.marker" asterisk_bin="${ASTERISK_BIN:-/usr/sbin/asterisk}" +if [[ "$asterisk_scope" == user ]]; then + [[ -n "${ASTERISK_BIN:-}" && -n "${ASTERISK_CONFIG:-}" && -r "$ASTERISK_CONFIG" ]] || { echo 'explicit user Asterisk configuration required; fail-closed'; exit 1; } + asterisk_lib="${ASTERISK_LIBRARY_PATH:-$(dirname "$asterisk_bin")/../lib}" + [[ -d "$asterisk_lib" ]] || { echo 'user Asterisk runtime libraries unavailable; fail-closed'; exit 1; } + asterisk_user_uid="$(id -u "$run_as")" || { echo 'user Asterisk service account unavailable; fail-closed'; exit 1; } +fi tcpdump_bin="${TCPDUMP_BIN:-$(command -v tcpdump || true)}" [[ -x "$asterisk_bin" ]] || { echo 'Asterisk CLI unavailable; fail-closed'; exit 1; } [[ -n "$tcpdump_bin" && -x "$tcpdump_bin" ]] || { echo 'tcpdump unavailable; fail-closed'; exit 1; } @@ -120,12 +130,34 @@ redact() { sed -E 's/(password|secret|token|authorization|api[_-]?key)[^[:space:]]*/\1=/Ig' } -if ! systemctl is-enabled asterisk.service >"$evidence_dir/asterisk-enabled.txt" 2>&1 || +asterisk_service() { + if [[ "$asterisk_scope" == user ]]; then + runuser -u "$run_as" -- env XDG_RUNTIME_DIR="/run/user/$asterisk_user_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$asterisk_user_uid/bus" systemctl --user "$@" go-sip-asterisk.service + else + systemctl "$@" asterisk.service + fi +} +asterisk_cli() { + if [[ "$asterisk_scope" == user ]]; then + runuser -u "$run_as" -- env LD_LIBRARY_PATH="$asterisk_lib" "$asterisk_bin" -C "$ASTERISK_CONFIG" -rx "$1" + else + "$asterisk_bin" -rx "$1" + fi +} +asterisk_journal() { + if [[ "$asterisk_scope" == user ]]; then + runuser -u "$run_as" -- env XDG_RUNTIME_DIR="/run/user/$asterisk_user_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$asterisk_user_uid/bus" journalctl --user -u go-sip-asterisk.service "$@" + else + journalctl -u asterisk.service "$@" + fi +} + +if ! asterisk_service is-enabled >"$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 || +if ! asterisk_service is-active >"$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 @@ -134,21 +166,21 @@ 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" +asterisk_cli "module show like res_ari.so" 2>&1 | redact >"$evidence_dir/ari-module-status.txt" +asterisk_cli "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" +asterisk_cli "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" +asterisk_cli "pjsip show contacts" 2>&1 | redact >"$evidence_dir/pjsip-contacts-before.txt" +asterisk_cli "core show channels verbose" 2>&1 | redact >"$evidence_dir/channels-before.txt" 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 @@ -263,7 +295,7 @@ cleanup() { trap - EXIT set +e stop_capture - if ((logger_enabled)) && ! "$asterisk_bin" -rx "pjsip set logger off" >"$evidence_dir/pjsip-logger-off.txt" 2>&1; then + if ((logger_enabled)) && ! asterisk_cli "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 @@ -284,7 +316,7 @@ cleanup() { 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 + if ! asterisk_journal --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 @@ -345,7 +377,7 @@ reserve_attempt() { } reserve_attempt -"$asterisk_bin" -rx "pjsip set logger on" >"$evidence_dir/pjsip-logger-on.txt" 2>&1 || { echo 'cannot enable PJSIP logger; fail-closed' >&2; exit 1; } +asterisk_cli "pjsip set logger on" >"$evidence_dir/pjsip-logger-on.txt" 2>&1 || { echo 'cannot enable PJSIP logger; fail-closed' >&2; exit 1; } logger_enabled=1 "$tcpdump_bin" -i "$interface" -nn -s0 -U -w "$evidence_dir/capture.pcap" \ @@ -369,8 +401,8 @@ call_status=$? set -e printf '%s\n' "call_exit=$call_status" stop_capture -"$asterisk_bin" -rx "pjsip show contacts" 2>&1 | redact >"$evidence_dir/pjsip-contacts-after.txt" -"$asterisk_bin" -rx "core show channels verbose" 2>&1 | redact >"$evidence_dir/channels-after.txt" +asterisk_cli "pjsip show contacts" 2>&1 | redact >"$evidence_dir/pjsip-contacts-after.txt" +asterisk_cli "core show channels verbose" 2>&1 | redact >"$evidence_dir/channels-after.txt" capture_packets="$(awk '/ packets captured/{print $1; exit}' "$evidence_dir/tcpdump.log" 2>/dev/null || true)" [[ "$capture_packets" =~ ^[0-9]+$ ]] || capture_packets=0 capture_status=0 diff --git a/internal/config/nonprod_evidence_cleanup_test.go b/internal/config/nonprod_evidence_cleanup_test.go index 982ab18..005a731 100644 --- a/internal/config/nonprod_evidence_cleanup_test.go +++ b/internal/config/nonprod_evidence_cleanup_test.go @@ -11,6 +11,82 @@ import ( "time" ) +func TestNonprodUserAsteriskScopeRequiresExplicitConfig(t *testing.T) { + tools := t.TempDir() + for name, script := range map[string]string{ + "id": "if [ \"$1\" = -u ]; then echo 0; else exec /usr/bin/id \"$@\"; fi\n", + "date": "if [ \"${TZ-}\" = Asia/Shanghai ] && [ \"$1\" = +%H%M ]; then echo 1000; else exec /usr/bin/date \"$@\"; fi\n", + } { + if err := os.WriteFile(filepath.Join(tools, name), []byte("#!/bin/sh\n"+script), 0700); err != nil { + t.Fatal(err) + } + } + currentUser, err := user.Current() + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + command := exec.Command("bash", "../../deploys/test/nonprod-call-evidence.sh", "--environment", "mock", + "--asterisk-scope", "user", "--trunk", "provider-primary", "--target", "15003164745", "--run-as", currentUser.Username, + "--interface", "lo", "--recording-dir", filepath.Join(root, "recordings"), "--evidence-dir", filepath.Join(root, "evidence"), + "--attempt-ledger", filepath.Join(root, "attempts.tsv"), "--preflight-only", "--", "/bin/true") + command.Env = append(os.Environ(), "PATH="+tools+":"+os.Getenv("PATH"), "ASTERISK_BIN=/bin/true", "ASTERISK_CONFIG=") + output, err := command.CombinedOutput() + if err == nil || !strings.Contains(string(output), "explicit user Asterisk configuration required") { + t.Fatalf("user-scope diagnostics must refuse unbound Asterisk instance: err=%v output=%s", err, output) + } +} + +func TestNonprodUserAsteriskScopeUsesUserServiceAndConfiguredCLI(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("runuser", "[ \"$1\" = -u ] && [ \"$3\" = -- ] || exit 99\nshift 3\nexec \"$@\"\n") + write("systemctl", "[ \"$1\" = --user ] && [ \"$3\" = go-sip-asterisk.service ] || exit 99\nprintf '%s\\n' \"$2\" >>\"$TEST_SERVICE_LOG\"\ncase \"$2\" in is-enabled) echo enabled;; is-active) echo active;; *) exit 99;; esac\n") + write("ip", "echo 'lo UNKNOWN 127.0.0.1/8'\n") + write("ss", "echo 'udp 127.0.0.1:5060'\n") + write("tcpdump", "case \" $* \" in *' -c 1 '*) exit 124;; esac\nexit 99\n") + asterisk := write("asterisk", "[ \"$1\" = -C ] && [ \"$2\" = \"$TEST_CONFIG\" ] && [ \"$3\" = -rx ] || exit 98\nprintf '%s\\n' \"$4\" >>\"$TEST_CLI_LOG\"\ncase \"$4\" 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: not-loaded';; *) exit 97;; esac\n") + root := t.TempDir() + configFile := filepath.Join(root, "asterisk.conf") + if err := os.WriteFile(configFile, []byte("[directories]\n"), 0600); err != nil { + t.Fatal(err) + } + currentUser, err := user.Current() + if err != nil { + t.Fatal(err) + } + serviceLog := filepath.Join(root, "service-checked") + cliLog := filepath.Join(root, "cli-checked") + command := exec.Command("bash", "../../deploys/test/nonprod-call-evidence.sh", "--environment", "mock", + "--asterisk-scope", "user", "--trunk", "provider-primary", "--target", "15003164745", "--run-as", currentUser.Username, + "--interface", "lo", "--recording-dir", filepath.Join(root, "recordings"), "--evidence-dir", filepath.Join(root, "evidence"), + "--attempt-ledger", filepath.Join(root, "attempts.tsv"), "--preflight-only", "--", "/bin/true") + command.Env = append(os.Environ(), "PATH="+tools+":"+os.Getenv("PATH"), "ASTERISK_BIN="+asterisk, "ASTERISK_CONFIG="+configFile, + "ASTERISK_LIBRARY_PATH="+tools, "TCPDUMP_BIN="+filepath.Join(tools, "tcpdump"), "TEST_CONFIG="+configFile, + "TEST_SERVICE_LOG="+serviceLog, "TEST_CLI_LOG="+cliLog) + output, err := command.CombinedOutput() + if err == nil || !strings.Contains(string(output), "PJSIP endpoint unavailable") { + t.Fatalf("missing user-service endpoint must block before dialing: err=%v output=%s", err, output) + } + serviceChecks, err := os.ReadFile(serviceLog) + if err != nil || !strings.Contains(string(serviceChecks), "is-enabled\nis-active\n") { + t.Fatalf("user service status was not verified: %v %q", err, serviceChecks) + } + cliChecks, err := os.ReadFile(cliLog) + if err != nil || !strings.Contains(string(cliChecks), "module show like res_ari.so\nhttp show status\npjsip show endpoint provider-primary\n") { + t.Fatalf("configured user Asterisk CLI was not used: %v %q", err, cliChecks) + } +} + func TestNonprodPreflightRejectsIncompleteCapturedEvidence(t *testing.T) { tools := t.TempDir() write := func(name, body string) string {