From ceb464dd686bbd8a990ba1d127368b47c6a157d3 Mon Sep 17 00:00:00 2001 From: Repin Agent Date: Fri, 17 Jul 2026 06:43:40 -0600 Subject: [PATCH] =?UTF-8?q?feat(sip):=20M7=20CP2=20=E2=80=94=20apply=20fro?= =?UTF-8?q?m=5Fuser/from=5Fdomain=20to=20the=20origination=20From?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build_invite_with_uri` hardcoded `sip:asterisk@` in the From, so a carrier that authorizes by caller identity (Chime rejects a From that is not a DID we own) could never be satisfied. Apply the endpoint's `from_user`/ `from_domain` to the outbound From (INVITE and in-dialog BYE), falling back to `asterisk` @ the signalling host:port when unset. - session: `from_uri()` builds `sip:user@domain`; used by `build_invite_with_uri` and `build_bye` so the From identity stays stable across the dialog. New `from_user`/`from_domain` session fields. Contact still advertises the reachable `asterisk@` (reachability, not identity). - channel_driver: resolve the endpoint's from_user/from_domain onto the outbound session (mirroring outbound_auth). Proofs: session cp2_tests (From carries user@domain; user-only keeps the signalling domain; unset -> asterisk; BYE From matches). RED-capable: revert -> `sip:asterisk@`. tests/cp2-from-identity — isolated offline-carrier harness asserting the captured INVITE From URI is exactly the configured identity (receiver-side). Captured RED: reverting yields `sip:asterisk@0.0.0.0:15060`. --- crates/asterisk-sip/src/channel_driver.rs | 11 + crates/asterisk-sip/src/session/mod.rs | 104 ++++++++- tests/cp2-from-identity/ami_originate.py | 65 ++++++ tests/cp2-from-identity/carrier_from.py | 126 +++++++++++ .../config/asterisk.conf.tmpl | 4 + .../cp2-from-identity/config/extensions.conf | 3 + tests/cp2-from-identity/config/manager.conf | 9 + .../cp2-from-identity/config/pjsip.conf.tmpl | 25 +++ tests/cp2-from-identity/config/rtp.conf | 3 + tests/cp2-from-identity/run.sh | 207 ++++++++++++++++++ 10 files changed, 555 insertions(+), 2 deletions(-) create mode 100755 tests/cp2-from-identity/ami_originate.py create mode 100755 tests/cp2-from-identity/carrier_from.py create mode 100644 tests/cp2-from-identity/config/asterisk.conf.tmpl create mode 100644 tests/cp2-from-identity/config/extensions.conf create mode 100644 tests/cp2-from-identity/config/manager.conf create mode 100644 tests/cp2-from-identity/config/pjsip.conf.tmpl create mode 100644 tests/cp2-from-identity/config/rtp.conf create mode 100755 tests/cp2-from-identity/run.sh diff --git a/crates/asterisk-sip/src/channel_driver.rs b/crates/asterisk-sip/src/channel_driver.rs index 4e3fab2..82fb403 100644 --- a/crates/asterisk-sip/src/channel_driver.rs +++ b/crates/asterisk-sip/src/channel_driver.rs @@ -603,6 +603,15 @@ impl ChannelDriver for SipChannelDriver { )) }); + // Apply the endpoint's outbound caller identity (`from_user`/ + // `from_domain`) to the origination From (CP2). A carrier that authorizes + // by caller ID (Chime rejects a From that is not a DID we own) needs a + // specific identity here rather than the internal `asterisk@`. + if let Some(ep) = endpoint_config.as_ref().and_then(|config| config.find_endpoint(dest)) { + sip_session.from_user = ep.from_user.clone(); + sip_session.from_domain = ep.from_domain.clone(); + } + // Create SDP offer with a concrete, routable connection address // (external_media_address / routed interface — never 0.0.0.0, // issue #56). Fail closed (CP3): if a configured external_media_address @@ -730,6 +739,8 @@ impl ChannelDriver for SipChannelDriver { early_media_config: session.early_media_config.clone(), outbound_auth: session.outbound_auth.clone(), auth_attempts: session.auth_attempts, + from_user: session.from_user.clone(), + from_domain: session.from_domain.clone(), }; handler.register_outbound_session( &session.call_id, diff --git a/crates/asterisk-sip/src/session/mod.rs b/crates/asterisk-sip/src/session/mod.rs index c40da67..de5dd9b 100644 --- a/crates/asterisk-sip/src/session/mod.rs +++ b/crates/asterisk-sip/src/session/mod.rs @@ -149,6 +149,13 @@ pub struct SipSession { /// the challenge/response loop so a carrier that keeps challenging cannot /// drive an unbounded resend (M-f "bounded retries"). pub auth_attempts: u32, + /// Outbound From user-part (`from_user`). A carrier that authorizes calls by + /// caller identity (e.g. Chime rejects a From that is not a DID we own) + /// requires a specific user here. `None` falls back to `asterisk`. + pub from_user: Option, + /// Outbound From host-part (`from_domain`). `None` falls back to the + /// signalling host:port advertised toward the peer. + pub from_domain: Option, } impl SipSession { @@ -176,6 +183,8 @@ impl SipSession { early_media_config: EarlyMediaConfig::default(), outbound_auth: None, auth_attempts: 0, + from_user: None, + from_domain: None, } } @@ -215,6 +224,8 @@ impl SipSession { early_media_config: EarlyMediaConfig::default(), outbound_auth: None, auth_attempts: 0, + from_user: None, + from_domain: None, }) } @@ -234,12 +245,25 @@ impl SipSession { self.build_invite_with_uri(to_uri, to_uri) } + /// The outbound From URI (`sip:user@domain`) this session presents. Honors + /// the endpoint's `from_user`/`from_domain` (a carrier expects a specific + /// caller identity — e.g. a DID it owns), falling back to `asterisk` @ the + /// signalling host:port. Used for the INVITE and every in-dialog request so + /// the From identity stays stable across the dialog. + pub fn from_uri(&self) -> String { + let user = self.from_user.as_deref().unwrap_or("asterisk"); + match self.from_domain.as_deref() { + Some(domain) => format!("sip:{user}@{domain}"), + None => format!("sip:{user}@{}", self.signaling_hostport()), + } + } + /// Build an INVITE with separate Request-URI and To header value. /// The request_uri is used as the actual SIP Request-URI (typically the /// contact address), while to_uri is used in the To header. pub fn build_invite_with_uri(&mut self, request_uri: &str, to_uri: &str) -> SipMessage { let sig = self.signaling_hostport(); - let from_uri = format!("sip:asterisk@{sig}"); + let from_uri = self.from_uri(); let contact_uri = format!("sip:asterisk@{sig}"); let branch = format!("z9hG4bK{}", &Uuid::new_v4().to_string().replace('-', "")[..16]); @@ -568,6 +592,9 @@ impl SipSession { /// Build a BYE request. pub fn build_bye(&mut self) -> Option { + // From identity must match the INVITE's (same user@domain + local tag) + // for the dialog's lifetime — carriers key in-dialog requests on it. + let from_base = self.from_uri(); let sig = self.signaling_hostport(); // Loose/strict routing per the established route set (Contact + Route), // so an in-dialog BYE follows the same proxy path as the ACK instead of @@ -591,7 +618,7 @@ impl SipSession { let branch = format!("z9hG4bK{}", &Uuid::new_v4().to_string().replace('-', "")[..16]); - let from_value = format!(";tag={}", sig, dialog.local_tag); + let from_value = format!("<{}>;tag={}", from_base, dialog.local_tag); let to_value = format!("<{}>;tag={}", dialog.remote_uri, dialog.remote_tag); @@ -955,3 +982,76 @@ mod cp1_tests { assert_eq!(s.auth_attempts, 0); } } + +#[cfg(test)] +mod cp2_tests { + use super::*; + + fn addr(s: &str) -> SocketAddr { + s.parse().unwrap() + } + + #[test] + fn from_user_domain_applied_to_invite_from() { + let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060")); + s.from_user = Some("+19995551234".to_string()); + s.from_domain = Some("carrier.example.net".to_string()); + let invite = s.build_invite_with_uri("sip:+15550001111@10.0.0.2:5060", "sip:+15550001111@10.0.0.2"); + let from = invite.from_header().unwrap().to_string(); + assert!( + from.contains("sip:+19995551234@carrier.example.net"), + "From must carry the configured user@domain, got: {from}" + ); + // The internal bind identity must be gone (the CP2 defect). + assert!(!from.contains("asterisk@"), "From still hardcodes asterisk: {from}"); + } + + #[test] + fn from_user_only_keeps_signalling_domain() { + let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060")); + s.from_user = Some("+19995551234".to_string()); + let invite = s.build_invite_with_uri("sip:x@10.0.0.2:5060", "sip:x@10.0.0.2"); + let from = invite.from_header().unwrap().to_string(); + assert!(from.contains("sip:+19995551234@"), "user not applied: {from}"); + // Domain falls back to the signalling host:port (the bind), not "asterisk". + assert!(from.contains("@10.0.0.1:5060"), "domain fallback wrong: {from}"); + } + + #[test] + fn from_defaults_to_asterisk_when_unset() { + let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060")); + let invite = s.build_invite_with_uri("sip:x@10.0.0.2:5060", "sip:x@10.0.0.2"); + assert!(invite.from_header().unwrap().contains("sip:asterisk@10.0.0.1:5060")); + } + + #[test] + fn bye_from_matches_configured_identity() { + // The in-dialog BYE From must carry the SAME user@domain as the INVITE. + let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060")); + s.from_user = Some("+19995551234".to_string()); + s.from_domain = Some("carrier.example.net".to_string()); + let invite = s.build_invite_with_uri("sip:+15550001111@10.0.0.2:5060", "sip:+15550001111@10.0.0.2"); + let ok = SipMessage::parse( + format!( + "SIP/2.0 200 OK\r\n\ + Via: {via}\r\n\ + From: {from}\r\n\ + To: ;tag=c200\r\n\ + Call-ID: {cid}\r\n\ + CSeq: 1 INVITE\r\n\ + Contact: \r\n\ + Content-Length: 0\r\n\r\n", + via = invite.get_header(header_names::VIA).unwrap(), + from = invite.from_header().unwrap(), + cid = s.call_id, + ) + .as_bytes(), + ) + .unwrap(); + s.on_response(&ok); + let bye = s.build_bye().unwrap(); + let from = bye.from_header().unwrap().to_string(); + assert!(from.contains("sip:+19995551234@carrier.example.net"), "BYE From wrong: {from}"); + assert!(!from.contains("asterisk@"), "BYE From still hardcodes asterisk: {from}"); + } +} diff --git a/tests/cp2-from-identity/ami_originate.py b/tests/cp2-from-identity/ami_originate.py new file mode 100755 index 0000000..02a9b37 --- /dev/null +++ b/tests/cp2-from-identity/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: cp2\r\n" + "Secret: cp2-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/cp2-from-identity/carrier_from.py b/tests/cp2-from-identity/carrier_from.py new file mode 100755 index 0000000..01b06a7 --- /dev/null +++ b/tests/cp2-from-identity/carrier_from.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/cp2-from-identity/config/asterisk.conf.tmpl b/tests/cp2-from-identity/config/asterisk.conf.tmpl new file mode 100644 index 0000000..08a069b --- /dev/null +++ b/tests/cp2-from-identity/config/asterisk.conf.tmpl @@ -0,0 +1,4 @@ +[directories] +astetcdir = @CONFIG_DIR@ +astrundir = @RUN_DIR@ +astincludedir = @RUN_DIR@/include diff --git a/tests/cp2-from-identity/config/extensions.conf b/tests/cp2-from-identity/config/extensions.conf new file mode 100644 index 0000000..45745c9 --- /dev/null +++ b/tests/cp2-from-identity/config/extensions.conf @@ -0,0 +1,3 @@ +[default] +exten => s,1,Wait(1) + same => n,Hangup() diff --git a/tests/cp2-from-identity/config/manager.conf b/tests/cp2-from-identity/config/manager.conf new file mode 100644 index 0000000..ba2294e --- /dev/null +++ b/tests/cp2-from-identity/config/manager.conf @@ -0,0 +1,9 @@ +[general] +enabled = yes +bindaddr = 0.0.0.0 +port = 15038 + +[cp2] +secret = cp2-local-only +read = all +write = system diff --git a/tests/cp2-from-identity/config/pjsip.conf.tmpl b/tests/cp2-from-identity/config/pjsip.conf.tmpl new file mode 100644 index 0000000..fb2d632 --- /dev/null +++ b/tests/cp2-from-identity/config/pjsip.conf.tmpl @@ -0,0 +1,25 @@ +; CP2 from-identity harness pjsip config. The `carrier` endpoint sets an +; outbound caller identity (from_user/from_domain) that a carrier authorizes on; +; the harness proves the outbound INVITE's From carries EXACTLY that identity, +; not the internal asterisk@. + +[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 +from_user = +19709601891 +from_domain = carrier.example.net + +[carrier_aor] +type = aor +contact = sip:carrier@@CORE_S@:5060 diff --git a/tests/cp2-from-identity/config/rtp.conf b/tests/cp2-from-identity/config/rtp.conf new file mode 100644 index 0000000..8ef5b32 --- /dev/null +++ b/tests/cp2-from-identity/config/rtp.conf @@ -0,0 +1,3 @@ +[general] +rtpstart = 31000 +rtpend = 31040 diff --git a/tests/cp2-from-identity/run.sh b/tests/cp2-from-identity/run.sh new file mode 100755 index 0000000..e71c0df --- /dev/null +++ b/tests/cp2-from-identity/run.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# CP2 from-identity harness. +# +# Proves RECEIVER-SIDE, on an isolated `--internal` Docker network, that an +# origination endpoint's configured `from_user`/`from_domain` are applied to the +# outbound INVITE's From — the offline carrier captures the INVITE and the +# harness asserts the From URI is exactly the configured identity, never a +# rustisk TX log. RED: revert CP2 -> From = sip:asterisk@ -> assertion RED. +# +# Isolated Docker only; never touches the live voice stack / carrier / real PIN. +# tests/cp2-from-identity/run.sh +set -euo pipefail + +HARNESS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd -- "$HARNESS_DIR/../.." && pwd)" +RUNTIME_DIR="$REPO_DIR/target/cp2-from-identity" +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="cp2-net-$$" +RUSTISK_CONTAINER="cp2-rustisk-$$" +CARRIER_CONTAINER="cp2-carrier-$$" +THIRD_OCTET="$((20 + ($$ % 200)))" +SUBNET="10.250.$THIRD_OCTET.0/24" +IP_RANGE="10.250.$THIRD_OCTET.32/27" +RUSTISK_IP="10.250.$THIRD_OCTET.2" +SECRET_DIR="" + +EXPECT_FROM_USER="+19709601891" +EXPECT_FROM_DOMAIN="carrier.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/cp2-pin-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 + +say '=== CP2 from-identity harness ===' +rm -rf "$RUNTIME_DIR"; mkdir -p "$CONFIG_DIR" "$RUN_DIR"; : >"$CAPTURE" + +SECRET_DIR="$(mktemp -d /mnt/data/herodevs-agents/cp2-pin-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 the INVITE From)...' +docker run -d --rm --name "$CARRIER_CONTAINER" \ + --network "$NET" --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$HARNESS_DIR/carrier_from.py,dst=/carrier_from.py,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=/runtime" \ + "$RUSTISK_IMAGE" python3 /carrier_from.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 container 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=$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 cp2-orig >/dev/null \ + || fail "AMI Originate failed to queue" + +wait_for_file_line "$CAPTURE" "INVITE-FROM " 15 || { docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true; fail "carrier never captured the INVITE From"; } + +FROM_URI="$(grep -m1 "INVITE-FROM " "$CAPTURE" | sed -n 's/.*from_uri=\([^ ]*\).*/\1/p')" +EXPECT="sip:${EXPECT_FROM_USER}@${EXPECT_FROM_DOMAIN}" +if [[ "$FROM_URI" == "$EXPECT" ]]; then + RESULT="PASS" +else + RESULT="FAIL" +fi + +{ + echo "CP2 from-identity harness — 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 "expected From URI: $EXPECT" + echo "captured From URI: $FROM_URI" + echo "result: $RESULT" + echo + echo "--- carrier capture (receiver-side) ---" + cat "$CAPTURE" 2>/dev/null || true +} >"$PROOF" + +say '' +say '================ VERDICT ================' +cat "$PROOF" + +if [[ "$RESULT" == "PASS" ]]; then + say '' + say "PASS: outbound INVITE From carries the configured identity ($EXPECT), receiver-side." + exit 0 +else + fail "CP2: From URI was '$FROM_URI', expected '$EXPECT'" +fi