diff --git a/bin/ami_call.py b/bin/ami_call.py new file mode 100755 index 0000000..a45fc7d --- /dev/null +++ b/bin/ami_call.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Low-level AMI transport for bin/call.sh (the migrated operational command). + +Subcommands (all read the AMI secret from a FILE, never argv/env value): + + list Print active channels, one per line: + CHANNEL\tEXTENSION\tCALLERIDNUM\tSTATE + originate Place one outbound call (Async). Exits 0 only if AMI queued it. + hangup Hang up ONE named channel. + +The AMI secret path (a mounted k8s Secret in production) is passed with +--secret-file; the value is read here and never printed. This replaces the ESL +password handling in the FreeSWITCH-era bin/call.sh. +""" + +import argparse +import socket +import sys +import uuid + + +def read_secret(path): + with open(path, "r") as f: + return f.read().strip() + + +def ami_txn(host, port, username, secret, action_lines, terminator): + """Login, send one action, read until `terminator` appears, logoff. Returns text.""" + login = "Action: Login\r\nUsername: %s\r\nSecret: %s\r\n\r\n" % (username, secret) + action = "".join("%s\r\n" % l for l in action_lines) + "\r\n" + logoff = "Action: Logoff\r\n\r\n" + buf = bytearray() + with socket.create_connection((host, port), timeout=6) as s: + s.settimeout(6) + s.sendall((login + action + logoff).encode("utf-8")) + try: + while terminator.encode("utf-8") not in buf and b"Response: Goodbye\r\n" not in buf: + chunk = s.recv(65536) + if not chunk: + break + buf.extend(chunk) + except socket.timeout: + pass + return buf.decode("utf-8", "replace") + + +def parse_events(text): + """Split an AMI stream into a list of dict blocks (keyed by header name).""" + blocks = [] + for raw in text.split("\r\n\r\n"): + raw = raw.strip("\r\n") + if not raw: + continue + d = {} + for line in raw.split("\r\n"): + if ":" in line: + k, v = line.split(":", 1) + d[k.strip()] = v.strip() + if d: + blocks.append(d) + return blocks + + +def cmd_list(args): + secret = read_secret(args.secret_file) + aid = uuid.uuid4().hex[:8] + text = ami_txn(args.host, args.port, args.username, secret, + ["Action: CoreShowChannels", "ActionID: %s" % aid], + "CoreShowChannelsComplete") + for b in parse_events(text): + if b.get("Event") == "CoreShowChannel": + sys.stdout.write("%s\t%s\t%s\t%s\n" % ( + b.get("Channel", ""), + b.get("Extension", ""), + b.get("CallerIDNum", ""), + b.get("ChannelStateDesc", ""), + )) + return 0 + + +def cmd_originate(args): + secret = read_secret(args.secret_file) + aid = uuid.uuid4().hex[:8] + lines = [ + "Action: Originate", + "ActionID: %s" % aid, + "Channel: %s" % args.channel, + "Context: %s" % args.context, + "Exten: %s" % args.exten, + "Priority: %d" % args.priority, + "Timeout: %d" % args.timeout, + "Async: true", + ] + if args.callerid: + lines.append("CallerID: %s" % args.callerid) + text = ami_txn(args.host, args.port, args.username, secret, lines, "successfully queued") + sys.stdout.write(text) + # The Login reply also carries "Success"; require the Originate-specific + # queued message so a failed Originate cannot false-pass. + return 0 if "successfully queued" in text else 2 + + +def cmd_hangup(args): + secret = read_secret(args.secret_file) + aid = uuid.uuid4().hex[:8] + text = ami_txn(args.host, args.port, args.username, secret, + ["Action: Hangup", "ActionID: %s" % aid, "Channel: %s" % args.channel], + "Response:") + sys.stdout.write(text) + return 0 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, default=5038) + ap.add_argument("--username", default="operator") + ap.add_argument("--secret-file", required=True) + sub = ap.add_subparsers(dest="cmd", required=True) + + sub.add_parser("list") + + o = sub.add_parser("originate") + o.add_argument("--channel", required=True) + o.add_argument("--context", default="default") + o.add_argument("--exten", default="s") + o.add_argument("--priority", type=int, default=1) + o.add_argument("--callerid", default="") + o.add_argument("--timeout", type=int, default=30000) + + h = sub.add_parser("hangup") + h.add_argument("--channel", required=True) + + args = ap.parse_args() + if args.cmd == "list": + sys.exit(cmd_list(args)) + if args.cmd == "originate": + sys.exit(cmd_originate(args)) + if args.cmd == "hangup": + sys.exit(cmd_hangup(args)) + + +if __name__ == "__main__": + main() diff --git a/bin/call.sh b/bin/call.sh new file mode 100755 index 0000000..8e283f1 --- /dev/null +++ b/bin/call.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Place an OUTBOUND call through rustisk and drop the callee into the agents' +# bridge — the AMI-native successor to the FreeSWITCH/ESL bin/call.sh (M-k). +# +# bin/call.sh 2000 # ring dest 2000 through the carrier +# bin/call.sh 2000 --dry-run # show the Originate, place NO call +# bin/call.sh --hangup 2000 # hang up the call(s) to dest 2000 +# bin/call.sh --hangup-all --force # GUARDED hupall: tear down EVERY channel +# +# vs the ESL original this is AMI-native (Action: Originate / CoreShowChannels / +# Hangup), reads its secret from a mounted k8s Secret file (never argv), refuses +# to stack a duplicate concurrent call to the same destination, and turns the old +# unconditional `hupall` foot-gun into an EXPLICIT, DOUBLY-GUARDED operation +# (--hangup-all AND --force both required). +# +# Config via env (all optional): +# AMI_HOST (127.0.0.1) AMI_PORT (5038) AMI_USERNAME (operator) +# AMI_SECRET_FILE (/run/secrets/rustisk-ami/secret) — the mounted k8s Secret +# CALL_ENDPOINT (carrier) CALL_CONTEXT (default) CALL_CALLERID ("") +# CALL_TIMEOUT_MS (30000) +set -euo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +AMI_PY="$HERE/ami_call.py" + +AMI_HOST="${AMI_HOST:-127.0.0.1}" +AMI_PORT="${AMI_PORT:-5038}" +AMI_USERNAME="${AMI_USERNAME:-operator}" +AMI_SECRET_FILE="${AMI_SECRET_FILE:-/run/secrets/rustisk-ami/secret}" +CALL_ENDPOINT="${CALL_ENDPOINT:-carrier}" +CALL_CONTEXT="${CALL_CONTEXT:-default}" +CALL_CALLERID="${CALL_CALLERID:-}" +CALL_TIMEOUT_MS="${CALL_TIMEOUT_MS:-30000}" + +DEST="" +DRY=0 +HANGUP=0 +HANGUP_ALL=0 +FORCE=0 +for a in "$@"; do + case "$a" in + --dry-run) DRY=1 ;; + --hangup) HANGUP=1 ;; + --hangup-all) HANGUP_ALL=1 ;; + --force) FORCE=1 ;; + --*) echo "usage: $0 DEST [--dry-run] | --hangup DEST | --hangup-all --force" >&2; exit 2 ;; + *) DEST="$a" ;; + esac +done + +die() { echo "$*" >&2; exit 1; } + +ami() { python3 "$AMI_PY" --host "$AMI_HOST" --port "$AMI_PORT" \ + --username "$AMI_USERNAME" --secret-file "$AMI_SECRET_FILE" "$@"; } + +[ -r "$AMI_SECRET_FILE" ] || die "AMI secret file not readable: $AMI_SECRET_FILE (mount the k8s Secret)" + +channel_prefix="PJSIP/${CALL_ENDPOINT}-" + +# --- --hangup-all: the GUARDED hupall (both --hangup-all AND --force) --------- +if [ "$HANGUP_ALL" = 1 ]; then + if [ "$FORCE" != 1 ]; then + echo "REFUSING to hang up EVERY channel." >&2 + echo "This is the old hupall foot-gun. Re-run with an explicit confirmation:" >&2 + echo " $0 --hangup-all --force" >&2 + exit 3 + fi + n=0 + while IFS=$'\t' read -r chan _exten _cid _state; do + [ -n "$chan" ] || continue + ami hangup --channel "$chan" >/dev/null || true + echo "hung up $chan" + n=$((n + 1)) + done < <(ami list) + echo "hupall complete: $n channel(s) hung up" + exit 0 +fi + +# --- --hangup DEST: tear down only the call(s) to DEST ------------------------ +if [ "$HANGUP" = 1 ]; then + [ -n "$DEST" ] || die "usage: $0 --hangup DEST (to hupall EVERY channel use --hangup-all --force)" + n=0 + while IFS=$'\t' read -r chan exten _cid _state; do + [ -n "$chan" ] || continue + if [ "$exten" = "$DEST" ] && [[ "$chan" == "$channel_prefix"* ]]; then + ami hangup --channel "$chan" >/dev/null || true + echo "hung up $chan (dest $DEST)" + n=$((n + 1)) + fi + done < <(ami list) + if [ "$n" = 0 ]; then echo "no active call to $DEST"; fi + exit 0 +fi + +# --- place a call ------------------------------------------------------------ +[ -n "$DEST" ] || die "usage: $0 DEST [--dry-run] | --hangup DEST | --hangup-all --force" + +CHANNEL="PJSIP/${CALL_ENDPOINT}" + +# Duplicate-call guard: refuse to stack a second concurrent call to the same +# destination (matched on the active channel's Extension + endpoint prefix). +if ami list | awk -F'\t' -v d="$DEST" -v p="$channel_prefix" \ + '$2==d && index($1,p)==1 {found=1} END{exit found?0:1}'; then + echo "a call to $DEST is already up -- refusing to place another" + echo "(use '$0 --hangup $DEST' to clear it)" + exit 0 +fi + +if [ "$DRY" = 1 ]; then + echo "DRY RUN — would AMI Originate:" + echo " Channel: $CHANNEL" + echo " Context: $CALL_CONTEXT" + echo " Exten: $DEST" + echo " CallerID: ${CALL_CALLERID:-}" + echo " (no call placed)" + exit 0 +fi + +echo ">> ringing $DEST via $CHANNEL (context $CALL_CONTEXT)" +if ami originate --channel "$CHANNEL" --context "$CALL_CONTEXT" --exten "$DEST" \ + --priority 1 --callerid "$CALL_CALLERID" --timeout "$CALL_TIMEOUT_MS" >/dev/null; then + echo ">> Originate queued for $DEST" + echo ">> hang up with: $0 --hangup $DEST" +else + die ">> Originate failed" +fi diff --git a/tests/cp4-call-command/carrier.py b/tests/cp4-call-command/carrier.py new file mode 100755 index 0000000..01b06a7 --- /dev/null +++ b/tests/cp4-call-command/carrier.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Minimal offline carrier for the CP2 from-identity harness (stdlib only). + +Captures the outbound INVITE's From header RECEIVER-SIDE and answers the call +(100 -> 200 with SDP -> consumes ACK/BYE) so the leg completes cleanly. The +harness asserts the captured From carries the endpoint's configured +from_user@from_domain — never a rustisk TX log. +""" + +import argparse +import re +import socket +import sys + + +SIP_PORT = 5060 + + +def log(msg): + sys.stderr.write("[carrier_from] " + msg + "\n") + sys.stderr.flush() + + +def own_ip_toward(peer_ip): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect((peer_ip, 15060)) + return s.getsockname()[0] + finally: + s.close() + + +def get_headers(text, name): + out = [] + for line in text.split("\r\n"): + if line == "": + break + if ":" in line: + hn, hv = line.split(":", 1) + if hn.strip().lower() == name.lower(): + out.append(hv.strip()) + return out + + +def get_header(text, name): + v = get_headers(text, name) + return v[0] if v else None + + +def append_capture(path, line): + with open(path, "a") as f: + f.write(line + "\n") + f.flush() + log(line) + + +def build_response(req_text, code, reason, own, to_tag=None, sdp=None): + vias = get_headers(req_text, "Via") + frm = get_header(req_text, "From") or "" + to = get_header(req_text, "To") or "" + call_id = get_header(req_text, "Call-ID") or "" + cseq = get_header(req_text, "CSeq") or "" + if to_tag and "tag=" not in to: + to = to + ";tag=%s" % to_tag + lines = ["SIP/2.0 %d %s" % (code, reason)] + for v in vias: + lines.append("Via: %s" % v) + lines.append("From: %s" % frm) + lines.append("To: %s" % to) + lines.append("Call-ID: %s" % call_id) + lines.append("CSeq: %s" % cseq) + lines.append("Contact: " % (own, SIP_PORT)) + body = sdp or "" + if body: + lines.append("Content-Type: application/sdp") + lines.append("Content-Length: %d" % len(body)) + lines.append("") + lines.append(body) + return ("\r\n".join(lines)).encode("utf-8") + + +def carrier_sdp(own): + return ( + "v=0\r\no=carrier 0 0 IN IP4 %s\r\ns=-\r\nc=IN IP4 %s\r\n" + "t=0 0\r\nm=audio 40000 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\n" + ) % (own, own) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--caller", required=True) + ap.add_argument("--capture", required=True) + args = ap.parse_args() + + own = own_ip_toward(args.caller) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", SIP_PORT)) + append_capture(args.capture, "READY own=%s" % own) + + while True: + try: + sock.settimeout(0.5) + data, src = sock.recvfrom(8192) + except socket.timeout: + continue + except OSError: + break + text = data.decode("utf-8", "replace") + first = text.split("\r\n", 1)[0] + if first.startswith("INVITE "): + frm = get_header(text, "From") or "?" + # Extract the bare URI (sip:user@domain) from the From value. + m = re.search(r'<([^>]+)>', frm) + uri = m.group(1) if m else frm + append_capture(args.capture, "INVITE-FROM own=%s from_uri=%s raw_from=%s" % (own, uri, frm)) + sock.sendto(build_response(text, 100, "Trying", own), src) + sock.sendto(build_response(text, 200, "OK", own, to_tag="cp2from200", sdp=carrier_sdp(own)), src) + elif first.startswith("BYE "): + append_capture(args.capture, "BYE own=%s" % own) + sock.sendto(build_response(text, 200, "OK", own), src) + # ACK: nothing to answer. + + +if __name__ == "__main__": + main() diff --git a/tests/cp4-call-command/config/asterisk.conf.tmpl b/tests/cp4-call-command/config/asterisk.conf.tmpl new file mode 100644 index 0000000..08a069b --- /dev/null +++ b/tests/cp4-call-command/config/asterisk.conf.tmpl @@ -0,0 +1,4 @@ +[directories] +astetcdir = @CONFIG_DIR@ +astrundir = @RUN_DIR@ +astincludedir = @RUN_DIR@/include diff --git a/tests/cp4-call-command/config/extensions.conf b/tests/cp4-call-command/config/extensions.conf new file mode 100644 index 0000000..abf4163 --- /dev/null +++ b/tests/cp4-call-command/config/extensions.conf @@ -0,0 +1,7 @@ +[default] +exten => 2000,1,Answer() + same => n,Wait(10) + same => n,Hangup() +exten => s,1,Answer() + same => n,Wait(10) + same => n,Hangup() diff --git a/tests/cp4-call-command/config/manager.conf b/tests/cp4-call-command/config/manager.conf new file mode 100644 index 0000000..36347a7 --- /dev/null +++ b/tests/cp4-call-command/config/manager.conf @@ -0,0 +1,9 @@ +[general] +enabled = yes +bindaddr = 0.0.0.0 +port = 15038 + +[operator] +secret = cp4-operator-secret +read = all +write = system diff --git a/tests/cp4-call-command/config/pjsip.conf.tmpl b/tests/cp4-call-command/config/pjsip.conf.tmpl new file mode 100644 index 0000000..4745173 --- /dev/null +++ b/tests/cp4-call-command/config/pjsip.conf.tmpl @@ -0,0 +1,18 @@ +[transport-udp] +type = transport +protocol = udp +bind = 0.0.0.0:15060 + +[carrier] +type = endpoint +context = default +disallow = all +allow = ulaw +direct_media = no +rtp_symmetric = yes +dtmf_mode = rfc4733 +aors = carrier_aor + +[carrier_aor] +type = aor +contact = sip:carrier@@CORE_S@:5060 diff --git a/tests/cp4-call-command/config/rtp.conf b/tests/cp4-call-command/config/rtp.conf new file mode 100644 index 0000000..8ef5b32 --- /dev/null +++ b/tests/cp4-call-command/config/rtp.conf @@ -0,0 +1,3 @@ +[general] +rtpstart = 31000 +rtpend = 31040 diff --git a/tests/cp4-call-command/run.sh b/tests/cp4-call-command/run.sh new file mode 100755 index 0000000..9494b78 --- /dev/null +++ b/tests/cp4-call-command/run.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# CP4 (M-k) — bin/call.sh migrated to an AMI operational command. +# +# Exercises the migrated command against the OFFLINE carrier (never live qa-sip), +# proving RECEIVER-SIDE (the carrier's captured datagrams) that: +# A --dry-run places NO call (carrier sees no INVITE); +# B a real call reaches the carrier (one INVITE); +# C duplicate guard refuses a second concurrent call to the same dest +# (carrier still sees exactly one INVITE); +# D --hangup-all without --force REFUSES (the hupall foot-gun is guarded — the +# live call is untouched, carrier sees no BYE); +# E --hangup-all --force performs the guarded hupall (carrier sees the BYE). +# +# The AMI secret is read from a mounted file (a k8s Secret in prod), never argv. +# Isolated Docker only; never touches the live voice stack / carrier / real PIN. +# tests/cp4-call-command/run.sh +set -euo pipefail + +HARNESS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd -- "$HARNESS_DIR/../.." && pwd)" +RUNTIME_DIR="$REPO_DIR/target/cp4-call-command" +CONFIG_DIR="$RUNTIME_DIR/config" +RUN_DIR="$RUNTIME_DIR/run" +RUSTISK_LOG="$RUNTIME_DIR/rustisk.log" +PROOF="$RUNTIME_DIR/PROOF.txt" +CAPTURE="$RUNTIME_DIR/carrier.log" + +RUSTISK_IMAGE="python@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3" + +NET="cp4-net-$$" +RUSTISK_CONTAINER="cp4-rustisk-$$" +CARRIER_CONTAINER="cp4-carrier-$$" +THIRD_OCTET="$((20 + ($$ % 200)))" +SUBNET="10.248.$THIRD_OCTET.0/24" +IP_RANGE="10.248.$THIRD_OCTET.32/27" +RUSTISK_IP="10.248.$THIRD_OCTET.2" +SECRET_DIR="" +DEST=2000 + +reap_container() { + local c="$1" hp i + docker inspect "$c" >/dev/null 2>&1 || return 0 + hp="" + for i in 1 2 3 4 5; do + hp="$(docker inspect -f '{{.State.Pid}}' "$c" 2>/dev/null || true)" + [[ -n "$hp" && "$hp" != "0" ]] && break + sleep 0.3 + done + if [[ -n "$hp" && "$hp" != "0" ]]; then + kill -TERM "$hp" 2>/dev/null || true + timeout 3 docker wait "$c" >/dev/null 2>&1 || true + if docker inspect -f '{{.State.Running}}' "$c" 2>/dev/null | grep -q true; then + kill -KILL "$hp" 2>/dev/null || true + timeout 3 docker wait "$c" >/dev/null 2>&1 || true + fi + fi + timeout 10 docker rm -f "$c" >/dev/null 2>&1 || true + for _ in $(seq 1 20); do docker inspect "$c" >/dev/null 2>&1 || return 0; sleep 0.25; done + return 1 +} + +cleanup() { + docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true + local leaked=0 + reap_container "$CARRIER_CONTAINER" || leaked=1 + reap_container "$RUSTISK_CONTAINER" || leaked=1 + timeout 10 docker network rm "$NET" >/dev/null 2>&1 || true + if [[ -n "$SECRET_DIR" && "$SECRET_DIR" == /mnt/data/herodevs-agents/cp4-secret.* ]]; then + rm -rf "$SECRET_DIR" + fi + local still_net="" + docker network inspect "$NET" >/dev/null 2>&1 && still_net="$NET" + if (( leaked == 1 )) || [[ -n "$still_net" ]]; then + printf 'CLEANUP WARNING: leaked docker resources — reap by hand (docker rm -f, docker network rm %s)\n' "$NET" >&2 + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +require_command() { command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"; } +say() { printf '%s\n' "$*"; } +container_ip() { docker inspect -f "{{(index .NetworkSettings.Networks \"$NET\").IPAddress}}" "$1" 2>/dev/null; } +# grep -c already prints the count (0 included) and exits 1 on no match; swallow +# only the exit code so we never emit a spurious second "0". +count() { grep -c -- "$1" "$CAPTURE" 2>/dev/null || true; } + +wait_for_file_line() { + local file="$1" pattern="$2" timeout="$3" + local deadline=$(( $(date +%s) + timeout )) + while (( $(date +%s) < deadline )); do + [[ -f "$file" ]] && grep -q -- "$pattern" "$file" && return 0 + sleep 0.3 + done + return 1 +} + +wait_for_rustisk_boot() { + local deadline=$(( $(date +%s) + 40 )) + while (( $(date +%s) < deadline )); do + docker logs "$RUSTISK_CONTAINER" 2>&1 | grep -q 'fully booted' && return 0 + if ! docker inspect -f '{{.State.Running}}' "$RUSTISK_CONTAINER" 2>/dev/null | grep -q true; then + docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true + fail "rustisk container exited during boot; see $RUSTISK_LOG" + fi + sleep 0.5 + done + return 1 +} + +wait_for_ami() { + local deadline=$(( $(date +%s) + 20 )) + while (( $(date +%s) < deadline )); do + docker exec "$RUSTISK_CONTAINER" python3 -c \ + "import socket,sys; s=socket.create_connection(('127.0.0.1',15038),2); d=s.recv(64); sys.exit(0 if d else 1)" \ + >/dev/null 2>&1 && return 0 + sleep 0.5 + done + return 1 +} + +# call.sh inside the rustisk container, pointed at the local AMI + mounted secret. +callsh() { + docker exec \ + -e AMI_HOST=127.0.0.1 -e AMI_PORT=15038 -e AMI_USERNAME=operator \ + -e AMI_SECRET_FILE=/run/secrets/rustisk-ami/secret \ + -e CALL_ENDPOINT=carrier -e CALL_CONTEXT=default \ + "$RUSTISK_CONTAINER" bash /bin-call/call.sh "$@" +} + +# --------------------------------------------------------------------------- +require_command docker; require_command python3; require_command cargo + +say '=== CP4 AMI operational command (bin/call.sh) harness ===' +rm -rf "$RUNTIME_DIR"; mkdir -p "$CONFIG_DIR" "$RUN_DIR"; : >"$CAPTURE" + +SECRET_DIR="$(mktemp -d /mnt/data/herodevs-agents/cp4-secret.XXXXXX)" +chmod 700 "$SECRET_DIR"; umask 077 +printf '%06d\n' "$(( (RANDOM * 32768 + RANDOM) % 1000000 ))" >"$SECRET_DIR/pin" +printf 'cp4-operator-secret' >"$SECRET_DIR/ami-secret" # matches manager.conf [operator] + +say "Building rustisk (Rust 1.97.0, CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS:-6})..." +( cd "$REPO_DIR" && CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-6}" cargo +1.97.0 build -p rustisk-cli ) +[[ -x "$REPO_DIR/target/debug/rustisk" ]] || fail "rustisk debug binary not built" + +sed -e "s|@CONFIG_DIR@|$CONFIG_DIR|g" -e "s|@RUN_DIR@|$RUN_DIR|g" \ + "$HARNESS_DIR/config/asterisk.conf.tmpl" >"$CONFIG_DIR/asterisk.conf" +cp "$HARNESS_DIR/config/manager.conf" "$CONFIG_DIR/manager.conf" +cp "$HARNESS_DIR/config/extensions.conf" "$CONFIG_DIR/extensions.conf" +cp "$HARNESS_DIR/config/rtp.conf" "$CONFIG_DIR/rtp.conf" +printf '[general]\nsecret_file = /run/secrets/rustisk/pin\n' >"$CONFIG_DIR/pin_gate.conf" + +say "Creating isolated --internal network $NET..." +docker network create --internal --subnet "$SUBNET" --ip-range "$IP_RANGE" "$NET" >/dev/null + +say 'Starting offline carrier (answers 200; captures INVITE + BYE)...' +docker run -d --rm --name "$CARRIER_CONTAINER" \ + --network "$NET" --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$HARNESS_DIR/carrier.py,dst=/carrier.py,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=/runtime" \ + "$RUSTISK_IMAGE" python3 /carrier.py --caller "$RUSTISK_IP" --capture /runtime/carrier.log >/dev/null +S="" +for _ in $(seq 1 40); do S="$(container_ip "$CARRIER_CONTAINER")"; [[ -n "$S" ]] && break; sleep 0.25; done +[[ -n "$S" ]] || fail "could not read carrier IP" +wait_for_file_line "$CAPTURE" "READY own=" 15 || fail "carrier never became ready" +say "Carrier IP = $S" + +sed -e "s|@CORE_S@|$S|g" "$HARNESS_DIR/config/pjsip.conf.tmpl" >"$CONFIG_DIR/pjsip.conf" + +say "Starting isolated rustisk at $RUSTISK_IP..." +docker run -d --rm --name "$RUSTISK_CONTAINER" \ + --network "$NET" --ip "$RUSTISK_IP" \ + --ulimit nofile=65536:65536 --user "$(id -u):$(id -g)" \ + --entrypoint /rustisk \ + --mount "type=bind,src=$REPO_DIR/target/debug/rustisk,dst=/rustisk,readonly" \ + --mount "type=bind,src=$REPO_DIR/bin,dst=/bin-call,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=$RUNTIME_DIR" \ + --mount "type=bind,src=$SECRET_DIR/pin,dst=/run/secrets/rustisk/pin,readonly" \ + --mount "type=bind,src=$SECRET_DIR/ami-secret,dst=/run/secrets/rustisk-ami/secret,readonly" \ + "$RUSTISK_IMAGE" -f -vvv -C "$CONFIG_DIR/asterisk.conf" >/dev/null + +wait_for_rustisk_boot || fail "rustisk did not report fully booted" +wait_for_ami || fail "rustisk AMI never became reachable" +say 'rustisk booted; AMI reachable.' + +# ============================================================================ +# A. --dry-run places NO call. +# ============================================================================ +A_OUT="$(callsh "$DEST" --dry-run 2>&1 || true)" +sleep 1 +if echo "$A_OUT" | grep -q "DRY RUN" && [[ "$(count 'INVITE-FROM ')" == "0" ]]; then + A="PASS"; else A="FAIL"; fi + +# ============================================================================ +# B. a real call reaches the carrier (exactly one INVITE). +# ============================================================================ +callsh "$DEST" >/dev/null 2>&1 || true +wait_for_file_line "$CAPTURE" "INVITE-FROM " 10 || { docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true; fail "real call never reached the carrier"; } +sleep 1 +if [[ "$(count 'INVITE-FROM ')" == "1" ]]; then B="PASS"; else B="FAIL"; fi + +# ============================================================================ +# C. duplicate-call guard refuses a second concurrent call to the same dest. +# ============================================================================ +C_OUT="$(callsh "$DEST" 2>&1 || true)" +sleep 1 +if echo "$C_OUT" | grep -q "already up" && [[ "$(count 'INVITE-FROM ')" == "1" ]]; then + C="PASS"; else C="FAIL"; fi + +# ============================================================================ +# D. --hangup-all WITHOUT --force refuses (foot-gun guarded; call untouched). +# ============================================================================ +set +e +D_OUT="$(callsh --hangup-all 2>&1)"; D_RC=$? +set -e +sleep 1 +if echo "$D_OUT" | grep -q "REFUSING" && [[ "$D_RC" != "0" ]] && [[ "$(count 'BYE ')" == "0" ]]; then + D="PASS"; else D="FAIL"; fi + +# ============================================================================ +# E. --hangup-all --force performs the guarded hupall (carrier sees the BYE). +# ============================================================================ +callsh --hangup-all --force >/dev/null 2>&1 || true +wait_for_file_line "$CAPTURE" "BYE " 10 || true +sleep 1 +if [[ "$(count 'BYE ')" -ge 1 ]]; then E="PASS"; else E="FAIL"; fi + +VERDICT_OK=1 +for r in "$A" "$B" "$C" "$D" "$E"; do [[ "$r" == "PASS" ]] || VERDICT_OK=0; done + +{ + echo "CP4 AMI operational command (bin/call.sh) — PROOF" + echo "generated: $(date -u +%FT%TZ)" + echo "rustisk HEAD: $(cd "$REPO_DIR" && git rev-parse --short HEAD 2>/dev/null || echo unknown)" + echo + echo "A --dry-run places no call: $A" + echo "B real call reaches carrier (1 INVITE): $B" + echo "C duplicate-call guard refuses 2nd identical call: $C" + echo "D --hangup-all without --force REFUSES (guarded): $D (rc=$D_RC)" + echo "E --hangup-all --force performs guarded hupall: $E" + echo + echo "--- A (dry-run) output ---"; echo "$A_OUT" + echo "--- C (duplicate) output ---"; echo "$C_OUT" + echo "--- D (hangup-all refuse) output ---"; echo "$D_OUT" + echo "--- carrier capture (receiver-side) ---"; cat "$CAPTURE" 2>/dev/null || true +} >"$PROOF" + +say ''; say '================ VERDICT ================'; cat "$PROOF" + +if (( VERDICT_OK == 1 )); then + say '' + say "PASS: bin/call.sh AMI migration — dry-run, duplicate guard, and guarded hupall all proven receiver-side." + exit 0 +else + fail "CP4 harness verdict FAILED (see verdict above)" +fi