chore: initialize go-sip repository

This commit is contained in:
2026-09-21 08:56:04 +08:00
commit 643b11b21f
309 changed files with 40521 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Physical Asterisk Cell input
The production Cell is physical-host business software managed by the approved
SIP management release, not a Docker service and not a Go Agent subprocess.
Asterisk and the SIP Agent are the only business-code services in this project;
MQ, OSS, AI and SaaS APIs remain externally supplied infrastructure.
The pinned source input is:
- Asterisk `22.10.1`
- Git commit `f0e408a7b0d829c85bf15fa4b487870a50cb3000`
- Archive `../packages/asterisk-22.10.1-source.tar.gz`
- SHA-256 `373c98f4d4a1b923b42def0aee03f4e36aca9d1c244a8eeda646da8a97f89663`
`build-asterisk-native.sh` can reproduce a native stage from the local pinned
source/dependency archives using the Debian package list in
`debian-build-packages.lock`; `install-asterisk-native.sh` installs that stage
and the systemd unit without overwriting `/etc/asterisk`. Before production use,
the Cell owner must verify its dependencies/licence/security review, install the
management-approved static `pjsip.conf`/ARI/RTP configuration, and review/start
the systemd unit explicitly. Do not silently substitute another Asterisk version or a
container image. The Go Agent package only consumes the resulting approved static Cell artifact
and reports its applied revision. Local validation may use isolated MQ/OSS/AI
fixtures, but those are not production deployments.
Before every real outbound attempt, the operator must obtain a fresh user
confirmation in the current conversation that names the SIP channel, raw target
number and capture plan. Real SIP outbound calls are permitted only from 09:00
(inclusive) through 20:00 (exclusive), Asia/Shanghai time; outside that window
the Agent/Dispatcher must fail closed rather than wait, retry, delay or switch
trunks. A prior confirmation does not authorize retries or additional targets;
failed calls must stop for a new confirmation.
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Asterisk SIP Cell 22.10.1 (physical host)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=asterisk
Group=asterisk
WorkingDirectory=/var/lib/asterisk
ExecStart=/usr/sbin/asterisk -f -U asterisk -G asterisk -vvvg
ExecStop=/usr/sbin/asterisk -rx "core stop now"
Restart=on-failure
RestartSec=5s
UMask=0077
LimitNOFILE=65536
PrivateTmp=yes
ProtectHome=yes
ReadWritePaths=/etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk
[Install]
WantedBy=multi-user.target
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
VERSION=22.10.1
PKG="$ROOT/deploys/packages"
SRC_ARCHIVE="$PKG/asterisk-$VERSION-source.tar.gz"
SRC_SHA="$PKG/asterisk-$VERSION-source.sha256"
DEPS="$PKG/asterisk-$VERSION-deps"
OUT=${OUT_DIR:-"$PKG/asterisk-$VERSION-native"}
JOBS=${JOBS:-1}
WORK=${WORK_DIR:-"$ROOT/.local/asterisk-build-$VERSION"}
[[ -f "$SRC_ARCHIVE" && -f "$SRC_SHA" ]] || { echo 'Asterisk source archive/checksum missing' >&2; exit 1; }
[[ -d "$DEPS" ]] || { echo 'Asterisk dependency cache missing' >&2; exit 1; }
command -v make >/dev/null || { echo 'make is required; install deploys/cell/debian-build-packages.lock' >&2; exit 1; }
sha256sum -c "$SRC_SHA"
rm -rf -- "$WORK" "$OUT"
mkdir -p "$WORK" "$OUT/cache"
cp "$DEPS"/*.tar.bz2 "$OUT/cache/"
tar -xzf "$SRC_ARCHIVE" -C "$WORK"
SRC="$WORK/asterisk-$VERSION"
cd "$SRC"
EXTERNALS_CACHE_DIR="$OUT/cache" ./configure --with-pjproject-bundled --with-jansson-bundled
EXTERNALS_CACHE_DIR="$OUT/cache" make -j"$JOBS"
STAGE="$WORK/stage"
rm -rf -- "$STAGE"
mkdir -p "$STAGE"
EXTERNALS_CACHE_DIR="$OUT/cache" make install DESTDIR="$STAGE"
# Cell configuration is management-owned and must not be overwritten by this
# binary package.
rm -rf -- "$STAGE/etc/asterisk"
tar -C "$STAGE" -cpf "$OUT/asterisk-$VERSION-native-stage.tar" .
cp "$ROOT/deploys/cell/asterisk.service" "$OUT/asterisk.service"
cp "$ROOT/deploys/cell/install-asterisk-native.sh" "$OUT/install-asterisk-native.sh"
chmod 0755 "$OUT/install-asterisk-native.sh"
(
cd "$OUT"
sha256sum "asterisk-$VERSION-native-stage.tar" > "asterisk-$VERSION-native-stage.tar.sha256"
)
printf 'native_stage=%s\nservice=%s\n' "$OUT/asterisk-$VERSION-native-stage.tar" "$OUT/asterisk.service"
+34
View File
@@ -0,0 +1,34 @@
# Debian 13 build inputs for the pinned native Asterisk 22.10.1 Cell.
# Install through the host's package manager; this is build tooling, not a
# deployed MQ/OSS/AI service. Review/lock repository package revisions before
# production release.
build-essential
pkg-config
autoconf-archive
libedit-dev
libjansson-dev
libsqlite3-dev
uuid-dev
libxml2-dev
libssl-dev
libcurl4-openssl-dev
bison
flex
libcap-dev
libspeex-dev
libspeexdsp-dev
libogg-dev
libvorbis-dev
libasound2-dev
portaudio19-dev
libsndfile1-dev
libspandsp-dev
libsrtp2-dev
libgsm1-dev
zlib1g-dev
libncurses-dev
libnewt-dev
libpopt-dev
libical-dev
libldap2-dev
xmlstarlet
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
[[ ${EUID} -eq 0 ]] || { echo 'install-asterisk-native.sh must run as root' >&2; exit 1; }
START=false
for arg in "$@"; do
case "$arg" in
--start) START=true ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
cd -- "$PACKAGE_DIR"
. /etc/os-release
[[ ${ID:-} == debian && ${VERSION_ID:-} == 13 ]] || { echo 'Debian 13 is required' >&2; exit 1; }
[[ $(uname -m) == x86_64 ]] || { echo 'amd64 host is required' >&2; exit 1; }
STAGE=asterisk-22.10.1-native-stage.tar
sha256sum -c "$STAGE.sha256"
[[ -f asterisk.service ]] || { echo 'asterisk.service is missing' >&2; exit 1; }
getent group asterisk >/dev/null || groupadd --system asterisk
id -u asterisk >/dev/null 2>&1 || useradd --system --home-dir /var/lib/asterisk --shell /usr/sbin/nologin --gid asterisk asterisk
# Keep management-owned /etc/asterisk configuration intact.
tar --exclude='etc/asterisk/*' -xpf "$STAGE" -C /
ldconfig
install -d -o asterisk -g asterisk -m 0750 /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk
install -o root -g root -m 0644 asterisk.service /etc/systemd/system/asterisk.service
systemctl daemon-reload
systemctl enable asterisk.service
if [[ "$START" == true ]]; then
systemctl restart asterisk.service
fi
/usr/sbin/asterisk -V
printf 'installed asterisk=22.10.1 start=%s config_preserved=true\n' "$START"
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env bash
# Capture-first entrypoint for every non-production mixed/real call attempt.
set -euo pipefail
usage() {
cat >&2 <<'EOF'
usage: nonprod-call-evidence.sh --trunk TRUNK --target NUMBER [options] -- COMMAND [ARG...]
Options:
--environment NAME Defaults to AGENT_ENVIRONMENT or development; production is refused.
--call-id ID Evidence directory suffix (default: UTC timestamp).
--evidence-dir DIR Evidence root (default: /var/lib/sip-go-agent/evidence/<call-id>).
--interface IFACE Capture interface (default: default-route interface; any fallback).
--run-as USER Run COMMAND as this non-root user (default: rogee).
--sip-port PORT SIP UDP port (default: 5060).
--rtp-start PORT RTP range start (default: 10000).
--rtp-end PORT RTP range end (default: 10800).
--preflight-only Start/stop capture and diagnostics without a call; do not require packets.
--attempt-ledger FILE Daily trunk/number attempt ledger (default: /var/lib/sip-go-agent/state/real-call-attempts.tsv).
EOF
exit 2
}
[[ "$(id -u)" == 0 ]] || { echo 'must run as root for tcpdump and Asterisk diagnostics' >&2; exit 1; }
environment="${AGENT_ENVIRONMENT:-development}"
call_id="$(date -u +%Y%m%dT%H%M%SZ)"
evidence_dir=""
recording_dir="/var/lib/sip-go-agent/recordings"
interface="any"
run_as="rogee"
sip_port=5060
rtp_start=10000
rtp_end=10800
trunk=""
target=""
call_command=()
preflight_only=0
attempt_ledger="/var/lib/sip-go-agent/state/real-call-attempts.tsv"
attempt_number=0
while (($#)); do
case "$1" in
--environment) [[ $# -ge 2 ]] || usage; environment=$2; shift 2 ;;
--call-id) [[ $# -ge 2 ]] || usage; call_id=$2; shift 2 ;;
--evidence-dir) [[ $# -ge 2 ]] || usage; evidence_dir=$2; shift 2 ;;
--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 ;;
--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 ;;
--preflight-only) preflight_only=1; shift ;;
--attempt-ledger) [[ $# -ge 2 ]] || usage; attempt_ledger=$2; shift 2 ;;
--trunk) [[ $# -ge 2 ]] || usage; trunk=$2; shift 2 ;;
--target) [[ $# -ge 2 ]] || usage; target=$2; shift 2 ;;
--) shift; call_command=("$@"); break ;;
-h|--help) usage ;;
*) echo "unknown option: $1" >&2; usage ;;
esac
done
[[ "$environment" != production ]] || { echo 'production requires the separate production gate' >&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; }
[[ "$attempt_ledger" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo 'invalid attempt ledger path' >&2; exit 1; }
[[ ${#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; }
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"
fi
if [[ -z "$evidence_dir" ]]; then
evidence_dir="/var/lib/sip-go-agent/evidence/$call_id"
fi
install -d -m 0700 "$evidence_dir"
umask 077
exec > >(tee "$evidence_dir/entrypoint.log") 2>&1
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}"
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; }
command -v runuser >/dev/null || { echo 'runuser unavailable; fail-closed'; exit 1; }
command -v flock >/dev/null || { echo 'flock unavailable for daily attempt gate; fail-closed'; exit 1; }
command -v python3 >/dev/null || { echo 'python3 unavailable for SIP evidence summary; fail-closed'; exit 1; }
# A successful one-packet probe or a timeout after opening the capture proves
# that the binary can open a raw capture socket; permission errors fail closed.
probe_status=0
timeout 2s "$tcpdump_bin" -i "$interface" -nn -c 1 -w /dev/null >/dev/null 2>"$evidence_dir/tcpdump-preflight.log" || probe_status=$?
if [[ "$probe_status" != 0 && "$probe_status" != 124 ]]; then
echo "tcpdump CAP_NET_RAW preflight failed: status=$probe_status" >&2
exit 1
fi
started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '{"environment":"%s","call_id":"%s","trunk":"%s","target":"%s","interface":"%s","attempt_ledger":"%s","attempt_number":%s,"sip_port":%s,"rtp_start":%s,"rtp_end":%s,"started_at":"%s"}\n' \
"$environment" "$call_id" "$trunk" "$target" "$interface" "$attempt_ledger" "$attempt_number" "$sip_port" "$rtp_start" "$rtp_end" "$started_at" >"$evidence_dir/metadata.json"
redact() {
sed -E 's/(password|secret|token|authorization|api[_-]?key)[^[:space:]]*/\1=<redacted>/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
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 "pjsip show endpoint $trunk" 2>&1 | redact >"$evidence_dir/pjsip-endpoint.txt"
"$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
logger_enabled=0
capture_pid=""
write_sip_summary() {
python3 - "$evidence_dir/asterisk-journal.txt" "$evidence_dir/call-output.private" >"$evidence_dir/sip-summary.json" <<'PY'
import json
import re
import sys
from pathlib import Path
journal = Path(sys.argv[1]).read_text(errors="replace") if Path(sys.argv[1]).exists() else ""
call_output = Path(sys.argv[2]).read_text(errors="replace") if Path(sys.argv[2]).exists() else ""
responses = []
methods = []
reason_headers = []
timeline = []
direction = None
current_response = None
sdp = {"present": False, "audio_ports": [], "codecs": [], "payload_types": [], "ptime": [], "directions": []}
def timestamp(line):
return line.split(" asterisk", 1)[0].strip() if " asterisk" in line else None
for line in journal.splitlines():
ts = timestamp(line)
if "Transmitting SIP request" in line:
direction = "outbound"
elif "Received SIP request" in line or "Received SIP response" in line:
direction = "inbound"
status = re.search(r"SIP/2\.0\s+(\d{3})(?:\s+(.+?))?\s*$", line)
if status:
item = {"timestamp": ts, "direction": direction, "code": int(status.group(1)), "reason": (status.group(2) or "").strip(), "cseq_method": None}
responses.append(item)
current_response = item
timeline.append({"timestamp": ts, "direction": direction, "event": f"{item['code']} {item['reason']}".strip()})
method = re.search(r"\b(INVITE|ACK|BYE|CANCEL)\s+(sip:\S+)\s+SIP/2\.0", line)
if method:
current_response = None
item = {"timestamp": ts, "direction": direction, "method": method.group(1), "request_uri": method.group(2)}
methods.append(item)
timeline.append({"timestamp": ts, "direction": direction, "event": method.group(1), "request_uri": method.group(2)})
cseq = re.search(r"\bCSeq:\s*\d+\s+([A-Za-z]+)", line)
if cseq and current_response is not None and current_response["cseq_method"] is None:
current_response["cseq_method"] = cseq.group(1).upper()
reason = re.search(r"\bReason:\s*(.+)$", line)
if reason:
value = reason.group(1).strip()
reason_headers.append(value)
q850 = re.search(r"Q\.850\s*;\s*cause\s*=\s*(\d+)", value, re.I)
else:
q850 = None
if "v=0" in line:
sdp["present"] = True
audio = re.search(r"m=audio\s+(\d+)", line)
if audio:
sdp["audio_ports"].append(int(audio.group(1)))
rtpmap = re.search(r"a=rtpmap:(\d+)\s+([^\s]+)", line)
if rtpmap:
sdp["payload_types"].append(int(rtpmap.group(1)))
sdp["codecs"].append(rtpmap.group(2))
ptime = re.search(r"a=ptime:\s*(\d+)", line)
if ptime:
sdp["ptime"].append(int(ptime.group(1)))
media_direction = re.search(r"a=(sendrecv|sendonly|recvonly|inactive)\s*$", line)
if media_direction:
sdp["directions"].append(media_direction.group(1))
hangup = re.search(r"cause=(\d+)", call_output)
invite_responses = [item for item in responses if item.get("cseq_method") in (None, "INVITE")]
final_response = next((item for item in reversed(invite_responses) if item["code"] >= 200), None)
summary = {
"responses": responses,
"invite_responses": invite_responses,
"final_response": final_response,
"methods": methods,
"bye": [item for item in methods if item["method"] == "BYE"],
"cancel": [item for item in methods if item["method"] == "CANCEL"],
"reason_headers": reason_headers,
"q850": next((re.search(r"Q\.850\s*;\s*cause\s*=\s*(\d+)", value, re.I).group(1) for value in reason_headers if re.search(r"Q\.850\s*;\s*cause\s*=\s*(\d+)", value, re.I)), None),
"asterisk_hangup_cause": int(hangup.group(1)) if hangup else None,
"sdp": sdp,
"timeline": timeline[:200],
"stasis_start_seen": "StasisStart" in journal,
"stasis_end_seen": "StasisEnd" in journal,
}
print(json.dumps(summary, ensure_ascii=False, sort_keys=True, indent=2))
PY
}
stop_capture() {
if [[ -n "$capture_pid" ]]; then
# Let libpcap drain packets already accepted by the kernel before SIGINT;
# short INVITE/404 calls otherwise can leave a header-only pcap.
sleep 2
fi
if [[ -n "$capture_pid" ]] && kill -0 "$capture_pid" 2>/dev/null; then
kill -INT "$capture_pid" 2>/dev/null || true
for _ in 1 2 3 4 5; do
kill -0 "$capture_pid" 2>/dev/null || break
sleep 1
done
kill -TERM "$capture_pid" 2>/dev/null || true
wait "$capture_pid" 2>/dev/null || true
fi
capture_pid=""
}
cleanup() {
set +e
stop_capture
if ((logger_enabled)); then
"$asterisk_bin" -rx "pjsip set logger off" >"$evidence_dir/pjsip-logger-off.txt" 2>&1 || true
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
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
}
trap cleanup EXIT
reserve_attempt() {
if ((preflight_only)); then
return
fi
local today count legacy_count metadata
today="$(date -u +%F)"
install -d -m 0700 "$(dirname "$attempt_ledger")"
touch "$attempt_ledger"
exec 9>>"$attempt_ledger.lock"
flock -x 9
count="$(awk -F '\t' -v d="$today" -v t="$trunk" -v n="$target" '$1 == d && $2 == t && $3 == n {count++} END {print count + 0}' "$attempt_ledger")"
legacy_count=0
while IFS= read -r metadata; do
if grep -q '"environment":"development"' "$metadata" \
&& grep -q '"call_id":"real-' "$metadata" \
&& grep -q "\\\"trunk\\\":\\\"$trunk\\\"" "$metadata" \
&& grep -q "\\\"target\\\":\\\"$target\\\"" "$metadata" \
&& grep -q "\\\"started_at\\\":\\\"$today" "$metadata"; then
legacy_count=$((legacy_count + 1))
fi
done < <(find /var/lib/sip-go-agent/evidence -mindepth 2 -maxdepth 2 -type f -name metadata.json -print 2>/dev/null)
if ((legacy_count > count)); then
count=$legacy_count
fi
if ((count >= 3)); then
printf 'attempt_rejected=quota\ndate=%s\ntrunk=%s\ntarget=%s\nknown_attempts=%s\nmax_attempts=3\n' \
"$today" "$trunk" "$target" "$count" >"$evidence_dir/attempt-rejected.txt"
flock -u 9
exec 9>&-
echo "daily SIP/number attempt limit reached: $trunk/$target has $count attempts on $today" >&2
exit 1
fi
attempt_number=$((count + 1))
printf '%s\t%s\t%s\t%s\t%s\n' "$today" "$trunk" "$target" "$call_id" "$started_at" >>"$attempt_ledger"
flock -u 9
exec 9>&-
printf '{"environment":"%s","call_id":"%s","trunk":"%s","target":"%s","interface":"%s","attempt_ledger":"%s","attempt_number":%s,"sip_port":%s,"rtp_start":%s,"rtp_end":%s,"started_at":"%s"}\n' \
"$environment" "$call_id" "$trunk" "$target" "$interface" "$attempt_ledger" "$attempt_number" "$sip_port" "$rtp_start" "$rtp_end" "$started_at" >"$evidence_dir/metadata.json"
}
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; }
logger_enabled=1
"$tcpdump_bin" -i "$interface" -nn -s0 -U -w "$evidence_dir/capture.pcap" \
"udp port $sip_port or (udp portrange $rtp_start-$rtp_end)" >"$evidence_dir/tcpdump.log" 2>&1 &
capture_pid=$!
sleep 1
kill -0 "$capture_pid" 2>/dev/null || { echo 'tcpdump exited before call; fail-closed' >&2; exit 1; }
printf '%s\n' "capture_started=$evidence_dir/capture.pcap"
if ((preflight_only)); then
sleep 1
stop_capture
printf 'call_exit=0\ncapture_packets=0\ncapture_status=0\npreflight_only=1\n' >"$evidence_dir/result.txt"
exit 0
fi
call_status=0
set +e
runuser -u "$run_as" -- "${call_command[@]}" >"$evidence_dir/call-output.private" 2>&1
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"
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
if ((capture_packets == 0)); then capture_status=2; fi
printf 'call_exit=%s\ncapture_packets=%s\ncapture_status=%s\nattempt_number=%s\n' "$call_status" "$capture_packets" "$capture_status" "$attempt_number" >"$evidence_dir/result.txt"
if ((call_status != 0)); then exit "$call_status"; fi
exit "$capture_status"