From 04a65c3d4c17370a34e55635ffdee599c0df3e26 Mon Sep 17 00:00:00 2001 From: Repin Agent Date: Fri, 17 Jul 2026 07:09:12 -0600 Subject: [PATCH] =?UTF-8?q?test(sip):=20M7=20CP5=20=E2=80=94=20origination?= =?UTF-8?q?=20signalling-scope=20analysis=20+=20regression=20(M6=20MINOR-1?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the M6 routing MINOR-1 ancillary-signalling-leak finding into an M7-scoped answer: which ancillary paths does a Chime origination actually exercise? Finding (README): NONE. AWS Chime Voice Connector termination is IP-authenticated (no outbound REGISTER); the origination path implements no transfer (no REFER), no subscription (no NOTIFY), no MESSAGE, and rustisk is the session-timer RESPONDER not refresher (no outbound UPDATE). The core INVITE (M6 CP2) and the in-dialog ACK/BYE/CANCEL (M7 CP1) already route through signaling_hostport()/advertised_signaling_hostport(), so the whole origination signalling path is already NAT-scoped. Scoping the genuinely ancillary paths (refer.rs/notify*.rs/messaging.rs/outbound_registration.rs/update.rs) is a real latent leak but OFF the M7 origination path — documented and deferred. Delivers a receiver-side regression (tests/cp5-origination-signaling-scope) that locks the origination scoping in: with external_signaling_address configured and the carrier outside local_net, the offline carrier asserts INVITE + ACK + BYE all advertise the external host and none leaks the raw 0.0.0.0 bind. Captured RED (CP5_MODE=red-noext): dropping external_signaling_address makes every request leak 0.0.0.0 -> assertion RED. No Rust changes. --- .../cp5-origination-signaling-scope/README.md | 47 ++++ .../ami_originate.py | 65 +++++ .../carrier_scope.py | 133 +++++++++++ .../config/asterisk.conf.tmpl | 4 + .../config/extensions.conf | 4 + .../config/manager.conf | 9 + .../config/pjsip.conf.tmpl | 20 ++ .../config/rtp.conf | 3 + tests/cp5-origination-signaling-scope/run.sh | 224 ++++++++++++++++++ 9 files changed, 509 insertions(+) create mode 100644 tests/cp5-origination-signaling-scope/README.md create mode 100755 tests/cp5-origination-signaling-scope/ami_originate.py create mode 100755 tests/cp5-origination-signaling-scope/carrier_scope.py create mode 100644 tests/cp5-origination-signaling-scope/config/asterisk.conf.tmpl create mode 100644 tests/cp5-origination-signaling-scope/config/extensions.conf create mode 100644 tests/cp5-origination-signaling-scope/config/manager.conf create mode 100644 tests/cp5-origination-signaling-scope/config/pjsip.conf.tmpl create mode 100644 tests/cp5-origination-signaling-scope/config/rtp.conf create mode 100755 tests/cp5-origination-signaling-scope/run.sh diff --git a/tests/cp5-origination-signaling-scope/README.md b/tests/cp5-origination-signaling-scope/README.md new file mode 100644 index 0000000..0a3dae6 --- /dev/null +++ b/tests/cp5-origination-signaling-scope/README.md @@ -0,0 +1,47 @@ +# CP5 — external signalling scope on origination-relevant ancillary paths + +Folded from the M6 routing review (MINOR-1): several **ancillary** signalling +paths still interpolate the raw internal bind (`local_addr`) into Via/From/Contact +instead of the NAT-scoped `advertised_signaling_hostport()`, so an external peer +could see the internal address. This checkpoint answers the M7-scoped question: +**which of those ancillary paths does a Chime origination actually exercise?** + +## Finding: none of the ancillary paths is on the M7 Chime origination path + +| Ancillary path | Emits raw bind? | On the Chime origination path? | +|---|---|---| +| `outbound_registration.rs` (REGISTER + its keepalive OPTIONS) | yes (`local_addr` in Via/Contact) | **No.** AWS Chime Voice Connector **termination is IP-authenticated** — rustisk does **not** REGISTER to the carrier (the incumbent `call.sh` documents `NOREG`; no outbound registration is started for origination). CP1's carrier auth is a **digest challenge answered on the INVITE**, not a REGISTER. | +| `refer.rs` (REFER / transfer) | yes | **No.** The M7 origination path implements no call transfer, so REFER is never sent. | +| `notify.rs` / `notify_service.rs` (NOTIFY) | yes (`127.0.0.1:5060` fallback / `local_addr`) | **No.** No subscription/event dialog is created on the origination path. | +| `messaging.rs` (MESSAGE) | yes | **No.** SIP MESSAGE is unrelated to origination. | +| `update.rs` (outbound UPDATE / session-timer refresh) | template-only today | **No.** rustisk selects `refresher=uac` and is the session-timer **responder**, not the refresher (`event_handler.rs`: "UAS-side refresh SCHEDULING is deferred"). It answers a peer's UPDATE/re-INVITE but does not originate refreshes, so it never puts an outbound UPDATE on the wire. | + +The **core** outbound INVITE (M6 CP2) **and** the in-dialog ACK / BYE / CANCEL +added in M7 CP1 already route through `signaling_hostport()` → +`advertised_signaling_hostport()`, so the entire origination signalling path is +NAT-scoped. There is therefore nothing origination-relevant left to scope in +this checkpoint. + +## What this checkpoint delivers instead + +1. **This analysis + deferral.** Scoping the genuinely ancillary paths + (REFER/NOTIFY/MESSAGE/REGISTER/UPDATE) remains a real latent leak but is + **off the M7 origination path** — deferred as an M6-MINOR-1 follow-up, to be + done when/if transfer, event subscriptions, MESSAGE, or an authenticated + outbound REGISTER are put on a carrier path. +2. **A receiver-side regression** (`run.sh`) that **locks in** the origination + path's scoping so a future change cannot silently reintroduce a bind leak on + INVITE/ACK/BYE. With `external_signaling_address = signaling.example.net` and + the carrier outside `local_net`, the offline carrier asserts every origination + request advertises the external host and **none** carries the raw `0.0.0.0` + bind. + +## Running + +``` +tests/cp5-origination-signaling-scope/run.sh # GREEN: fully scoped +CP5_MODE=red-noext tests/cp5-origination-signaling-scope/run.sh # RED: drop the + # external address -> every request leaks 0.0.0.0 (assertion RED) +``` + +Isolated Docker only — never the live voice stack, carrier, or real PIN. diff --git a/tests/cp5-origination-signaling-scope/ami_originate.py b/tests/cp5-origination-signaling-scope/ami_originate.py new file mode 100755 index 0000000..9444629 --- /dev/null +++ b/tests/cp5-origination-signaling-scope/ami_originate.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Trigger one rustisk outbound Dial via a single authenticated AMI Originate. + + ami_originate.py HOST PORT ENDPOINT ACTION_ID + +Sends `Originate Channel: PJSIP/` asynchronously. rustisk resolves the +endpoint's contact (live registrar binding preferred over static config) and +sends the INVITE to it. Prints the AMI response; exits nonzero if the action was +not queued. +""" + +import socket +import sys + + +def main(): + if len(sys.argv) != 5: + raise SystemExit("usage: ami_originate.py HOST PORT ENDPOINT ACTION_ID") + host, port, endpoint, action_id = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4] + + login = ( + "Action: Login\r\n" + "Username: cp5\r\n" + "Secret: cp5-local-only\r\n" + "\r\n" + ) + originate = ( + "Action: Originate\r\n" + "ActionID: %s\r\n" + "Channel: PJSIP/%s\r\n" + "Context: default\r\n" + "Exten: s\r\n" + "Priority: 1\r\n" + "Timeout: 4000\r\n" + "Async: true\r\n" + "\r\n" + ) % (action_id, endpoint) + logoff = "Action: Logoff\r\n\r\n" + + payload = (login + originate + logoff).encode("utf-8") + response = bytearray() + with socket.create_connection((host, port), timeout=4) as mgr: + mgr.settimeout(4) + mgr.sendall(payload) + try: + while b"Response: Goodbye\r\n" not in response: + chunk = mgr.recv(65536) + if not chunk: + break + response.extend(chunk) + except socket.timeout: + pass + + text = response.decode("utf-8", "replace") + sys.stdout.write(text) + # Require the Originate-specific queued message. A bare "Success" is NOT + # sufficient: the Login reply also carries "Success", so matching it would + # green-light a session whose Originate actually failed. + if "successfully queued" not in text: + sys.stderr.write("Originate not queued\n") + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/tests/cp5-origination-signaling-scope/carrier_scope.py b/tests/cp5-origination-signaling-scope/carrier_scope.py new file mode 100755 index 0000000..fe8e807 --- /dev/null +++ b/tests/cp5-origination-signaling-scope/carrier_scope.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Offline carrier that captures the advertised signalling host on EVERY request +of a full origination (INVITE, ACK, BYE) for the CP5 signalling-scope harness. + +With `external_signaling_address` configured and the carrier outside `local_net`, +a scope-correct rustisk advertises the EXTERNAL address (not the raw `0.0.0.0` +bind) in Via/From/Contact on the core INVITE AND the in-dialog ACK/BYE. This +carrier records the host of those headers receiver-side so the harness can assert +no internal-bind leak on the origination path. +""" + +import argparse +import re +import socket +import sys + + +SIP_PORT = 5060 + + +def log(m): + sys.stderr.write("[carrier_scope] " + m + "\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 via_host(text): + via = get_header(text, "Via") or "" + m = re.search(r'SIP/2\.0/UDP\s+([^;\s]+)', via) + return m.group(1) if m else "?" + + +def uri_host(header_value): + if not header_value: + return "?" + m = re.search(r'<[a-z]+:[^@>]*@([^;>]+)', header_value) + if m: + return m.group(1) + m = re.search(r'[a-z]+:[^@>]*@([^;>]+)', header_value) + return m.group(1) if m else "?" + + +def cap(path, line): + with open(path, "a") as f: + f.write(line + "\n"); f.flush() + log(line) + + +def build_response(req, code, reason, own, to_tag=None, sdp=None): + vias = get_headers(req, "Via") + frm = get_header(req, "From") or "" + to = get_header(req, "To") or "" + cid = get_header(req, "Call-ID") or "" + cseq = get_header(req, "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 += ["From: %s" % frm, "To: %s" % to, "Call-ID: %s" % cid, "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 += ["", 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) + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("0.0.0.0", SIP_PORT)) + cap(args.capture, "READY own=%s" % own) + while True: + try: + s.settimeout(0.5) + data, src = s.recvfrom(8192) + except socket.timeout: + continue + except OSError: + break + text = data.decode("utf-8", "replace") + first = text.split("\r\n", 1)[0] + method = first.split(" ", 1)[0] + if method in ("INVITE", "ACK", "BYE"): + cap(args.capture, "%s via_host=%s from_host=%s contact_host=%s src=%s:%d" % ( + method, via_host(text), + uri_host(get_header(text, "From")), + uri_host(get_header(text, "Contact")) if get_header(text, "Contact") else "-", + src[0], src[1])) + if method == "INVITE": + s.sendto(build_response(text, 100, "Trying", own), src) + s.sendto(build_response(text, 200, "OK", own, to_tag="cp5scope200", sdp=carrier_sdp(own)), src) + elif method == "BYE": + s.sendto(build_response(text, 200, "OK", own), src) + + +if __name__ == "__main__": + main() diff --git a/tests/cp5-origination-signaling-scope/config/asterisk.conf.tmpl b/tests/cp5-origination-signaling-scope/config/asterisk.conf.tmpl new file mode 100644 index 0000000..08a069b --- /dev/null +++ b/tests/cp5-origination-signaling-scope/config/asterisk.conf.tmpl @@ -0,0 +1,4 @@ +[directories] +astetcdir = @CONFIG_DIR@ +astrundir = @RUN_DIR@ +astincludedir = @RUN_DIR@/include diff --git a/tests/cp5-origination-signaling-scope/config/extensions.conf b/tests/cp5-origination-signaling-scope/config/extensions.conf new file mode 100644 index 0000000..d30395f --- /dev/null +++ b/tests/cp5-origination-signaling-scope/config/extensions.conf @@ -0,0 +1,4 @@ +[default] +exten => s,1,Answer() + same => n,Wait(1) + same => n,Hangup() diff --git a/tests/cp5-origination-signaling-scope/config/manager.conf b/tests/cp5-origination-signaling-scope/config/manager.conf new file mode 100644 index 0000000..d2ba8f1 --- /dev/null +++ b/tests/cp5-origination-signaling-scope/config/manager.conf @@ -0,0 +1,9 @@ +[general] +enabled = yes +bindaddr = 0.0.0.0 +port = 15038 + +[cp5] +secret = cp5-local-only +read = all +write = system diff --git a/tests/cp5-origination-signaling-scope/config/pjsip.conf.tmpl b/tests/cp5-origination-signaling-scope/config/pjsip.conf.tmpl new file mode 100644 index 0000000..8f4043e --- /dev/null +++ b/tests/cp5-origination-signaling-scope/config/pjsip.conf.tmpl @@ -0,0 +1,20 @@ +[transport-udp] +type = transport +protocol = udp +bind = 0.0.0.0:15060 +local_net = 127.0.0.0/8 +external_signaling_address = signaling.example.net + +[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/cp5-origination-signaling-scope/config/rtp.conf b/tests/cp5-origination-signaling-scope/config/rtp.conf new file mode 100644 index 0000000..8ef5b32 --- /dev/null +++ b/tests/cp5-origination-signaling-scope/config/rtp.conf @@ -0,0 +1,3 @@ +[general] +rtpstart = 31000 +rtpend = 31040 diff --git a/tests/cp5-origination-signaling-scope/run.sh b/tests/cp5-origination-signaling-scope/run.sh new file mode 100755 index 0000000..d8236aa --- /dev/null +++ b/tests/cp5-origination-signaling-scope/run.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# CP5 (M6 routing MINOR-1, folded) — origination signalling-scope regression. +# +# Locks in, RECEIVER-SIDE, that the M7 origination path advertises the configured +# `external_signaling_address` (never the raw `0.0.0.0` bind) on the CORE INVITE +# AND the in-dialog ACK/BYE, when the carrier is outside `local_net`. See +# README.md for the ancillary-path (REFER/NOTIFY/MESSAGE/REGISTER/UPDATE) +# analysis and deferral — none of them is on the Chime origination path. +# +# RED: unset external_signaling_address -> every request leaks `0.0.0.0` (the +# M6 MINOR-1 bind leak) -> the "external present, no raw bind" assertion RED. +# +# Isolated Docker only; never touches the live voice stack / carrier / real PIN. +# tests/cp5-origination-signaling-scope/run.sh [CP5_MODE=green|red-noext] +set -euo pipefail + +HARNESS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd -- "$HARNESS_DIR/../.." && pwd)" +RUNTIME_DIR="$REPO_DIR/target/cp5-origination-signaling-scope" +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="cp5s-net-$$" +RUSTISK_CONTAINER="cp5s-rustisk-$$" +CARRIER_CONTAINER="cp5s-carrier-$$" +THIRD_OCTET="$((20 + ($$ % 200)))" +SUBNET="10.247.$THIRD_OCTET.0/24" +IP_RANGE="10.247.$THIRD_OCTET.32/27" +RUSTISK_IP="10.247.$THIRD_OCTET.2" +SECRET_DIR="" +MODE="${CP5_MODE:-green}" +EXPECT_EXT="signaling.example.net" + +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/cp5s-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; } + +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 +} + +# --------------------------------------------------------------------------- +require_command docker; require_command python3; require_command cargo +case "$MODE" in green|red-noext) ;; *) fail "invalid CP5_MODE='$MODE'";; esac + +say "=== CP5 origination signalling-scope harness (mode=$MODE) ===" +rm -rf "$RUNTIME_DIR"; mkdir -p "$CONFIG_DIR" "$RUN_DIR"; : >"$CAPTURE" + +SECRET_DIR="$(mktemp -d /mnt/data/herodevs-agents/cp5s-secret.XXXXXX)" +chmod 700 "$SECRET_DIR"; umask 077 +printf '%06d\n' "$(( (RANDOM * 32768 + RANDOM) % 1000000 ))" >"$SECRET_DIR/pin" + +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 (captures Via/From/Contact host of INVITE, ACK, BYE)...' +docker run -d --rm --name "$CARRIER_CONTAINER" \ + --network "$NET" --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$HARNESS_DIR/carrier_scope.py,dst=/carrier_scope.py,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=/runtime" \ + "$RUSTISK_IMAGE" python3 /carrier_scope.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" +if [[ "$MODE" == "red-noext" ]]; then + # RED: drop external_signaling_address so the origination path leaks the bind. + sed -i '/external_signaling_address/d' "$CONFIG_DIR/pjsip.conf" + say "RED mode: external_signaling_address REMOVED (expect a raw-bind leak)." +fi + +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=$HARNESS_DIR/ami_originate.py,dst=/ami_originate.py,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=$RUNTIME_DIR" \ + --mount "type=bind,src=$SECRET_DIR/pin,dst=/run/secrets/rustisk/pin,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.' + +say 'AMI Originate PJSIP/carrier ...' +docker exec -i "$RUSTISK_CONTAINER" python3 /ami_originate.py 127.0.0.1 15038 carrier cp5-orig >/dev/null \ + || fail "AMI Originate failed to queue" + +wait_for_file_line "$CAPTURE" "^INVITE " 15 || { docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true; fail "carrier never saw the INVITE"; } +wait_for_file_line "$CAPTURE" "^ACK " 15 || true +wait_for_file_line "$CAPTURE" "^BYE " 15 || true +sleep 1 + +# Every origination request must advertise the external addr, none the raw bind. +LEAK="$(grep -E '^(INVITE|ACK|BYE) ' "$CAPTURE" | grep -E 'host=0\.0\.0\.0' || true)" +SCOPED_INVITE="$(grep -m1 '^INVITE ' "$CAPTURE" | grep -c "via_host=$EXPECT_EXT" || true)" +SCOPED_ACK="$(grep -m1 '^ACK ' "$CAPTURE" | grep -c "via_host=$EXPECT_EXT" || true)" +SCOPED_BYE="$(grep -m1 '^BYE ' "$CAPTURE" | grep -c "via_host=$EXPECT_EXT" || true)" + +if [[ -z "$LEAK" && "$SCOPED_INVITE" == "1" && "$SCOPED_ACK" == "1" && "$SCOPED_BYE" == "1" ]]; then + RESULT="PASS" +else + RESULT="FAIL" +fi + +{ + echo "CP5 origination signalling-scope harness — PROOF (mode=$MODE)" + echo "generated: $(date -u +%FT%TZ)" + echo "rustisk HEAD: $(cd "$REPO_DIR" && git rev-parse --short HEAD 2>/dev/null || echo unknown)" + echo "expected external signalling host: $EXPECT_EXT" + echo + echo "INVITE advertises external: $SCOPED_INVITE" + echo "ACK advertises external: $SCOPED_ACK" + echo "BYE advertises external: $SCOPED_BYE" + echo "raw-bind (0.0.0.0) leaks: $([[ -z "$LEAK" ]] && echo none || echo "$LEAK")" + echo "result: $RESULT" + echo + echo "--- carrier capture (receiver-side) ---"; cat "$CAPTURE" 2>/dev/null || true +} >"$PROOF" + +say ''; say '================ VERDICT ================'; cat "$PROOF" + +if [[ "$MODE" == "green" ]]; then + [[ "$RESULT" == "PASS" ]] || fail "CP5: origination path did not fully scope (see verdict)" + say ''; say "PASS: INVITE + ACK + BYE all advertise $EXPECT_EXT; no raw-bind leak on the origination path." + exit 0 +else + [[ "$RESULT" == "FAIL" ]] || fail "CP5 RED mode expected a leak but found none (assertion cannot fail!)" + say ''; say "RED captured: without external_signaling_address the origination path leaks the raw bind (assertion RED)." + exit 0 +fi