From ef72782942ee4534b51c9d92186540828444c145 Mon Sep 17 00:00:00 2001 From: Repin Agent Date: Fri, 17 Jul 2026 05:31:07 -0600 Subject: [PATCH 1/2] test(sip): isolated-Docker container-restart REGISTER acceptance harness (M6 CP5, B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-demand harness (tests/cp5-register-restart/) proving rustisk's dynamic authenticated REGISTER end-to-end with a REAL bridge container restart on an isolated --internal Docker network — the literal CP5/B4 acceptance that PR #154 landed only as an in-process mechanism proof. A sip-bridge container digest-REGISTERs (401->digest->200) advertising its Docker-assigned IP A; Dial(PJSIP/bridge) lands an INVITE at A (captured receiver-side on the bridge). The bridge container is destroyed, a sentinel seizes the vacated A, and a fresh bridge comes up with a new IP B (B!=A, enforced) and re-REGISTERs. A subsequent Dial FOLLOWS to B receiver-side and NOT to stale A (sentinel silent). Both stale-A and fresh-B bindings stay live, so best_contact's newest-wins is genuinely load-bearing. A/B are read at runtime, never hardcoded. RED negative control (captured): routing is defeated by a static contact pinned to A (bridge_pinned); the Dial misroutes to stale A, the sentinel catches it, and the follow-to-B assertion goes RED — proving the A-detection is real. Reaps every container/network on exit (host-PID kill for tron's userns-remapped docker); generates a throwaway test PIN, shredded on exit; never touches the live stack. Not wired into CI (on-demand). --- tests/cp5-register-restart/README.md | 76 ++++ tests/cp5-register-restart/ami_originate.py | 62 +++ .../config/asterisk.conf.tmpl | 4 + .../config/extensions.conf | 6 + .../cp5-register-restart/config/manager.conf | 12 + .../config/pjsip.conf.tmpl | 55 +++ tests/cp5-register-restart/config/rtp.conf | 3 + tests/cp5-register-restart/run.sh | 384 ++++++++++++++++++ tests/cp5-register-restart/sip_agent.py | 292 +++++++++++++ 9 files changed, 894 insertions(+) create mode 100644 tests/cp5-register-restart/README.md create mode 100755 tests/cp5-register-restart/ami_originate.py create mode 100644 tests/cp5-register-restart/config/asterisk.conf.tmpl create mode 100644 tests/cp5-register-restart/config/extensions.conf create mode 100644 tests/cp5-register-restart/config/manager.conf create mode 100644 tests/cp5-register-restart/config/pjsip.conf.tmpl create mode 100644 tests/cp5-register-restart/config/rtp.conf create mode 100755 tests/cp5-register-restart/run.sh create mode 100755 tests/cp5-register-restart/sip_agent.py diff --git a/tests/cp5-register-restart/README.md b/tests/cp5-register-restart/README.md new file mode 100644 index 0000000..136412a --- /dev/null +++ b/tests/cp5-register-restart/README.md @@ -0,0 +1,76 @@ +# CP5 (B4) container-restart REGISTER acceptance harness + +This on-demand harness proves rustisk's **dynamic authenticated REGISTER** +end-to-end with a **real container restart** on an isolated `--internal` Docker +network — the literal CP5/B4 acceptance that PR #154 landed only as an +in-process mechanism proof (`crates/asterisk-integration-tests/tests/e2e_dynamic_register.rs`). + +It never touches Kubernetes, the live voice stack, the carrier trunk, Chime, +Mumble, the live phone number, or the real PIN. It mirrors the isolation and +Docker-reap discipline of `tests/freeswitch-pin-gate/`. + +```console +tests/cp5-register-restart/run.sh +``` + +`CP5_CASE=all` (default) runs GREEN + RED. `green`/`red` run a subset. Every +container and network is reaped on exit via a trap. + +## What it proves (receiver-side) + +The core rule of B4: **a static contact is wrong because a pod restart gives the +bridge a new IP.** The harness watches the INVITE *arrive* on the bridge/sentinel +(actual datagram receipt), never a rustisk TX log. + +1. **GREEN-A.** A `sip-bridge` container digest-REGISTERs (`401 → digest → 200`) + advertising its Docker-assigned container IP **A**. `Dial(PJSIP/bridge)` (via + AMI `Originate`) sends an INVITE that the bridge captures at **A**. +2. **restart.** The bridge container is destroyed. A **sentinel** container seizes + the now-vacated **A** (so a stale-route INVITE has a receiver *and* Docker + cannot hand A back). A fresh bridge container comes up with a **new IP B** + (`B ≠ A`, enforced) and re-REGISTERs from B. +3. **GREEN-B.** `Dial(PJSIP/bridge)` now lands at **B** (captured on the new + bridge) and **not** at stale A (the sentinel stays silent). rustisk holds both + the stale-A and fresh-B bindings live; `Registrar::best_contact` returns the + newest (B), so this is a genuine discrimination, not a trivial single-binding + route. +4. **RED (captured negative control).** Routing is defeated by a **static + contact pinned to A** (`bridge_pinned`, whose AoR carries `contact = + sip:bridge@A:5060` and never re-registers). `Dial(PJSIP/bridge_pinned)` + misroutes to stale A; the sentinel catches the INVITE and the follow-to-B + assertion goes **RED**. This proves the A-detection is real — the harness can + fail — so a green GREEN-B is meaningful. (The complementary + `best_contact` oldest-wins RED is covered directly in-process by + `e2e_dynamic_register.rs`.) + +A and B are **read at runtime** (`docker inspect`), never hardcoded. rustisk's +own address is fixed and kept out of the dynamic IP pool via `--ip-range`. + +## Addresses and IP assignment + +- Network: `--internal`, `10.252..0/24`, dynamic pool `…​.32/27`. +- rustisk: fixed `…​.2` (outside the dynamic pool). +- bridge A, bridge B, sentinel: Docker-assigned from the dynamic pool. + +## Files + +| File | Role | +|------|------| +| `run.sh` | orchestrator: network, containers, phases, PASS/FAIL verdict, reap trap, `PROOF.txt` | +| `sip_agent.py` | UDP SIP agent — `--role bridge` (digest REGISTER + receiver-side INVITE capture, answers 100/486) or `--role sentinel` (seizes vacated A, captures stray INVITEs) | +| `ami_originate.py` | one authenticated AMI `Originate` to trigger a Dial | +| `config/pjsip.conf.tmpl` | `bridge` (dynamic AoR, digest auth) + `bridge_pinned` (static-A RED control); `@PINNED_A@` filled at runtime | +| `config/{asterisk,manager,extensions,rtp}.conf*` | rustisk runtime config | + +## Notes / environment + +- rustisk fails closed without a mounted PIN secret, so the harness generates a + throwaway random six-digit **test** PIN, mounts it read-only, and shreds it on + exit. No PIN value is logged or committed. +- tron's docker is userns-remapped: the daemon cannot `SIGKILL` a container + running as our uid (`docker stop`/`rm -f` hang). Containers run + `--user $(id -u)`, so the reaper signals the **host PID** we own, then removes + the container. Do not replace `reap_container` with a bare `docker rm -f`. +- Build context and volumes live under `target/` (ext4), never `/tmp` (a tmpfs + RAM disk on tron). +- Requires `docker`, `python3`, and `cargo +1.97.0`. diff --git a/tests/cp5-register-restart/ami_originate.py b/tests/cp5-register-restart/ami_originate.py new file mode 100755 index 0000000..a2e8790 --- /dev/null +++ b/tests/cp5-register-restart/ami_originate.py @@ -0,0 +1,62 @@ +#!/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) + if "successfully queued" not in text and "Success" not in text: + sys.stderr.write("Originate not queued\n") + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/tests/cp5-register-restart/config/asterisk.conf.tmpl b/tests/cp5-register-restart/config/asterisk.conf.tmpl new file mode 100644 index 0000000..08a069b --- /dev/null +++ b/tests/cp5-register-restart/config/asterisk.conf.tmpl @@ -0,0 +1,4 @@ +[directories] +astetcdir = @CONFIG_DIR@ +astrundir = @RUN_DIR@ +astincludedir = @RUN_DIR@/include diff --git a/tests/cp5-register-restart/config/extensions.conf b/tests/cp5-register-restart/config/extensions.conf new file mode 100644 index 0000000..0ffae9a --- /dev/null +++ b/tests/cp5-register-restart/config/extensions.conf @@ -0,0 +1,6 @@ +; Minimal dialplan so a dialplan loads (AMI Originate requires one). The +; outbound INVITE to the bridge is created by the Originate's Channel itself +; (PJSIP/); this context is only the nominal answer target. +[default] +exten => s,1,Answer() + same => n,Hangup() diff --git a/tests/cp5-register-restart/config/manager.conf b/tests/cp5-register-restart/config/manager.conf new file mode 100644 index 0000000..482da5a --- /dev/null +++ b/tests/cp5-register-restart/config/manager.conf @@ -0,0 +1,12 @@ +[general] +enabled = yes +bindaddr = 0.0.0.0 +port = 15038 + +; Least-privilege driver account for the harness. `write = system` is exactly +; what AMI Originate requires; a regression breaking the Originate->SYSTEM +; mapping would make this harness's Dial trigger fail rather than silently pass. +[cp5] +secret = cp5-local-only +read = all +write = system diff --git a/tests/cp5-register-restart/config/pjsip.conf.tmpl b/tests/cp5-register-restart/config/pjsip.conf.tmpl new file mode 100644 index 0000000..42553dc --- /dev/null +++ b/tests/cp5-register-restart/config/pjsip.conf.tmpl @@ -0,0 +1,55 @@ +; CP5 container-restart REGISTER harness pjsip config. +; +; `bridge` — digest-authenticated endpoint with a DYNAMIC contact. It +; sets no `aors=`, so outbound resolution falls back to the +; endpoint name ("bridge"), which is exactly the AoR the bridge +; binds under when it REGISTERs `To: `. +; A live registration therefore routes Dial(PJSIP/bridge) to +; whatever address the bridge last registered from. +; +; `bridge_pinned` — the RED negative control. Its AoR carries a STATIC contact +; frozen at address A (the bridge's first, pre-restart IP). +; Nothing ever registers under `bridge_pinned_aor`, so +; Dial(PJSIP/bridge_pinned) always resolves to the stale static +; A — the exact "a static contact is wrong after a pod restart" +; bug B4 fixes. After the restart this misroutes to A, where +; the sentinel catches it and the follow-to-B assertion goes +; RED. +; +; No `type=identify` anywhere: the source ACL stays open so the bridge can +; REGISTER from its dynamic (unpredictable) container address. + +[transport-udp] +type = transport +protocol = udp +bind = 0.0.0.0:15060 + +[bridge] +type = endpoint +context = default +disallow = all +allow = ulaw +direct_media = no +rtp_symmetric = yes +dtmf_mode = rfc4733 +auth = bridgeauth + +[bridgeauth] +type = auth +auth_type = userpass +username = bridge +password = bridgepass + +[bridge_pinned] +type = endpoint +context = default +disallow = all +allow = ulaw +direct_media = no +rtp_symmetric = yes +dtmf_mode = rfc4733 +aors = bridge_pinned_aor + +[bridge_pinned_aor] +type = aor +contact = sip:bridge@@PINNED_A@:5060 diff --git a/tests/cp5-register-restart/config/rtp.conf b/tests/cp5-register-restart/config/rtp.conf new file mode 100644 index 0000000..8ef5b32 --- /dev/null +++ b/tests/cp5-register-restart/config/rtp.conf @@ -0,0 +1,3 @@ +[general] +rtpstart = 31000 +rtpend = 31040 diff --git a/tests/cp5-register-restart/run.sh b/tests/cp5-register-restart/run.sh new file mode 100755 index 0000000..8624ab5 --- /dev/null +++ b/tests/cp5-register-restart/run.sh @@ -0,0 +1,384 @@ +#!/usr/bin/env bash +# CP5 (B4) container-restart REGISTER acceptance harness. +# +# Proves, end-to-end with a REAL container restart on an isolated `--internal` +# Docker network, that rustisk's dynamic authenticated REGISTER routes an +# outbound Dial to a bridge's CURRENT container IP and FOLLOWS a restart to the +# bridge's NEW IP — verified RECEIVER-SIDE (the INVITE datagram's arrival on the +# bridge/sentinel, never a rustisk TX log). +# +# GREEN-A bridge digest-REGISTERs advertising container IP A; Dial(PJSIP/ +# bridge) INVITE arrives at A (captured on the bridge). +# restart bridge container is destroyed; a sentinel seizes the vacated A; a +# fresh bridge container comes up with a NEW IP B and re-REGISTERs. +# GREEN-B Dial(PJSIP/bridge) INVITE arrives at B and NOT at stale A (sentinel +# silent). +# RED Dial(PJSIP/bridge_pinned) — routing defeated by a STATIC contact +# pinned to A — misroutes to stale A; the sentinel catches it and the +# follow-to-B assertion goes RED. This negative control proves the +# A-detection is real (the harness can fail). +# +# Isolated Docker only: it never touches the live voice stack, Helm, k8s, the +# carrier trunk, or the real PIN. A throwaway six-digit TEST pin is generated, +# mounted read-only (rustisk fails closed without one), and shredded on exit. +# +# tests/cp5-register-restart/run.sh +# +# Env: CP5_CASE=all|green|red (default all). All Docker is reaped on exit. +set -euo pipefail + +HARNESS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd -- "$HARNESS_DIR/../.." && pwd)" +RUNTIME_DIR="$REPO_DIR/target/cp5-register-restart" +CONFIG_DIR="$RUNTIME_DIR/config" +RUN_DIR="$RUNTIME_DIR/run" +RUSTISK_LOG="$RUNTIME_DIR/rustisk.log" +PROOF="$RUNTIME_DIR/PROOF.txt" +BRIDGE_CAPTURE="$RUNTIME_DIR/bridge-invites.log" +SENTINEL_CAPTURE="$RUNTIME_DIR/sentinel-invites.log" +BRIDGE_STATUS="$RUNTIME_DIR/bridge-status.log" +SENTINEL_STATUS="$RUNTIME_DIR/sentinel-status.log" + +# The rustisk container carries the daemon binary bind-mounted into a pinned +# python image (same pattern as tests/freeswitch-pin-gate). python3 is the SIP +# agents' and AMI driver's only runtime dependency. +RUSTISK_IMAGE="python@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3" + +NET="cp5-net-$$" +RUSTISK_CONTAINER="cp5-rustisk-$$" +BRIDGE_CONTAINER="cp5-bridge-$$" +SENTINEL_CONTAINER="cp5-sentinel-$$" +THIRD_OCTET="$((20 + ($$ % 200)))" +SUBNET="10.252.$THIRD_OCTET.0/24" +# Dynamic pool is a small high sub-range so the bridge/sentinel never collide +# with rustisk's fixed low address. A and B are still Docker-assigned (never +# hardcoded) — just constrained to .32-.63. +IP_RANGE="10.252.$THIRD_OCTET.32/27" +RUSTISK_IP="10.252.$THIRD_OCTET.2" # fixed; A and B stay DYNAMIC / runtime-read +SECRET_DIR="" +CASE="${CP5_CASE:-all}" + +# tron's docker is userns-remapped: the daemon cannot SIGKILL a container whose +# process runs as our uid (`docker stop`/`docker rm -f` hang or 'permission +# denied'). The containers run `--user $(id -u)`, so WE own their host PID and +# can signal it. Reap = kill the host PID, then rm. +reap_container() { + local c="$1" hp + docker inspect "$c" >/dev/null 2>&1 || return 0 + hp="$(docker inspect -f '{{.State.Pid}}' "$c" 2>/dev/null || true)" + 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 + docker rm -f "$c" >/dev/null 2>&1 || true + # Wait for the name/endpoint to be released. + for _ in $(seq 1 20); do docker inspect "$c" >/dev/null 2>&1 || return 0; sleep 0.25; done +} + +cleanup() { + # Snapshot rustisk logs before teardown (best-effort). + docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true + reap_container "$SENTINEL_CONTAINER" + reap_container "$BRIDGE_CONTAINER" + reap_container "$RUSTISK_CONTAINER" + docker network rm "$NET" >/dev/null 2>&1 || true + if [[ -n "$SECRET_DIR" && "$SECRET_DIR" == /mnt/data/herodevs-agents/cp5-pin-secret.* ]]; then + rm -rf "$SECRET_DIR" + 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 +} + +count_lines() { [[ -f "$1" ]] && wc -l <"$1" | tr -d ' ' || echo 0; } + +# Wait until $file has a line beyond line number $prev that matches $pattern. +# Echoes the matching line; returns 1 on timeout. +wait_new_line() { + local file="$1" prev="$2" pattern="$3" timeout="$4" + local deadline=$(( $(date +%s) + timeout )) + while (( $(date +%s) < deadline )); do + if [[ -f "$file" ]]; then + local total; total="$(count_lines "$file")" + if (( total > prev )); then + local hit + hit="$(tail -n +"$((prev + 1))" "$file" | grep -m1 -- "$pattern" || true)" + if [[ -n "$hit" ]]; then printf '%s\n' "$hit"; return 0; fi + fi + fi + sleep 0.3 + done + return 1 +} + +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 + if docker logs "$RUSTISK_CONTAINER" 2>&1 | grep -q 'fully booted'; then return 0; fi + 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 + if 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; then + return 0 + fi + sleep 0.5 + done + return 1 +} + +originate() { + # originate ENDPOINT ACTION_ID + docker exec -i "$RUSTISK_CONTAINER" python3 /ami_originate.py 127.0.0.1 15038 "$1" "$2" +} + +# --------------------------------------------------------------------------- +require_command docker +require_command python3 +require_command cargo + +say '=== CP5 container-restart REGISTER harness ===' +rm -rf "$RUNTIME_DIR" +mkdir -p "$CONFIG_DIR" "$RUN_DIR" +: >"$BRIDGE_CAPTURE"; : >"$SENTINEL_CAPTURE"; : >"$BRIDGE_STATUS"; : >"$SENTINEL_STATUS" + +# Throwaway random TEST pin (rustisk fails closed without a mounted secret). +# Generated locally, mounted read-only, never logged, shredded on exit — a pure +# test value, unrelated to any production secret. +SECRET_DIR="$(mktemp -d /mnt/data/herodevs-agents/cp5-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" + +# Static rustisk config that does not depend on A. +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 ($SUBNET, dynamic pool $IP_RANGE)..." +docker network create --internal --subnet "$SUBNET" --ip-range "$IP_RANGE" "$NET" >/dev/null + +# --- Bring up the bridge FIRST so we can read its Docker-assigned IP A -------- +say 'Starting sip-bridge (advertises its own container IP, digest-REGISTERs)...' +docker run -d --rm --name "$BRIDGE_CONTAINER" \ + --network "$NET" \ + --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$HARNESS_DIR/sip_agent.py,dst=/sip_agent.py,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=/runtime" \ + "$RUSTISK_IMAGE" python3 /sip_agent.py --role bridge --registrar "$RUSTISK_IP" \ + --capture /runtime/bridge-invites.log --status /runtime/bridge-status.log >/dev/null + +A="" +for _ in $(seq 1 40); do A="$(container_ip "$BRIDGE_CONTAINER")"; [[ -n "$A" ]] && break; sleep 0.25; done +[[ -n "$A" ]] || fail "could not read bridge container IP A" +say "Bridge container IP A = $A" + +# --- Generate pjsip.conf pinning bridge_pinned's STATIC contact at A ---------- +sed -e "s|@PINNED_A@|$A|g" "$HARNESS_DIR/config/pjsip.conf.tmpl" >"$CONFIG_DIR/pjsip.conf" + +# --- Start rustisk (fixed IP R) ---------------------------------------------- +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 (127.0.0.1:15038) never became reachable" +say 'rustisk booted; AMI reachable.' + +wait_for_file_line "$BRIDGE_STATUS" "REGISTERED own=$A" 90 \ + || fail "bridge never completed digest REGISTER from A=$A" +say "Bridge digest-REGISTERed from A=$A (401 -> digest -> 200)." + +# ============================================================================ +# GREEN-A: with the bridge live at A, Dial(PJSIP/bridge) must arrive at A. +# ============================================================================ +GREEN_A=SKIP +prev="$(count_lines "$BRIDGE_CAPTURE")" +originate bridge cp5-green-a >/dev/null || fail "AMI Originate (green-a) failed" +if line="$(wait_new_line "$BRIDGE_CAPTURE" "$prev" "own=$A" 15)"; then + say "GREEN-A pass: INVITE arrived RECEIVER-SIDE at A. [$line]" + GREEN_A=PASS +else + GREEN_A=FAIL + docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true + fail "GREEN-A: no INVITE datagram observed at bridge A=$A" +fi + +# ============================================================================ +# RESTART: destroy the bridge, seize the vacated A with a sentinel, bring up a +# fresh bridge that MUST get a new IP B, then re-REGISTER from B. +# ============================================================================ +say 'Restarting bridge container (new IP, re-REGISTER)...' +reap_container "$BRIDGE_CONTAINER" + +# Seize A so Docker cannot hand it back to the new bridge and so a stale-route +# INVITE to A has a RECEIVER that captures it. +docker run -d --rm --name "$SENTINEL_CONTAINER" \ + --network "$NET" \ + --ip "$A" \ + --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$HARNESS_DIR/sip_agent.py,dst=/sip_agent.py,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=/runtime" \ + "$RUSTISK_IMAGE" python3 /sip_agent.py --role sentinel --registrar "$RUSTISK_IP" \ + --capture /runtime/sentinel-invites.log --status /runtime/sentinel-status.log >/dev/null +wait_for_file_line "$SENTINEL_STATUS" "REGISTERED own=$A" 15 \ + || fail "sentinel did not come up holding vacated A=$A" +say "Sentinel seized vacated A=$A." + +docker run -d --rm --name "$BRIDGE_CONTAINER" \ + --network "$NET" \ + --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$HARNESS_DIR/sip_agent.py,dst=/sip_agent.py,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=/runtime" \ + "$RUSTISK_IMAGE" python3 /sip_agent.py --role bridge --registrar "$RUSTISK_IP" \ + --capture /runtime/bridge-invites.log --status /runtime/bridge-status.log >/dev/null + +B="" +for _ in $(seq 1 40); do B="$(container_ip "$BRIDGE_CONTAINER")"; [[ -n "$B" ]] && break; sleep 0.25; done +[[ -n "$B" ]] || fail "could not read restarted bridge container IP B" +[[ "$B" != "$A" ]] || fail "restarted bridge came back with the SAME IP ($B); A/B not distinct" +say "Restarted bridge container IP B = $B (distinct from A=$A)." + +wait_for_file_line "$BRIDGE_STATUS" "REGISTERED own=$B" 90 \ + || fail "restarted bridge never completed digest REGISTER from B=$B" +say "Bridge re-REGISTERed from B=$B (401 -> digest -> 200)." + +# ============================================================================ +# GREEN-B: Dial(PJSIP/bridge) INVITE must FOLLOW to B and NOT hit stale A. +# ============================================================================ +GREEN_B=SKIP +if [[ "$CASE" == "all" || "$CASE" == "green" ]]; then + bprev="$(count_lines "$BRIDGE_CAPTURE")" + sprev="$(count_lines "$SENTINEL_CAPTURE")" + originate bridge cp5-green-b >/dev/null || fail "AMI Originate (green-b) failed" + if line="$(wait_new_line "$BRIDGE_CAPTURE" "$bprev" "own=$B" 15)"; then + # Give any stray datagram to A a moment to have landed, then assert none. + sleep 1 + snow="$(count_lines "$SENTINEL_CAPTURE")" + if (( snow > sprev )); then + GREEN_B=FAIL + fail "GREEN-B: a datagram reached STALE A after restart: $(tail -n1 "$SENTINEL_CAPTURE")" + fi + say "GREEN-B pass: INVITE FOLLOWED to B receiver-side; stale A silent. [$line]" + GREEN_B=PASS + else + GREEN_B=FAIL + docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true + fail "GREEN-B: INVITE did not follow to B=$B" + fi +fi + +# ============================================================================ +# RED negative control: defeat routing via the STATIC contact pinned to A. +# The SAME follow-to-B assertion must go RED (INVITE reaches stale A). +# ============================================================================ +RED=SKIP +if [[ "$CASE" == "all" || "$CASE" == "red" ]]; then + say 'RED negative control: Dial(PJSIP/bridge_pinned) — static contact pinned to stale A...' + bprev="$(count_lines "$BRIDGE_CAPTURE")" + sprev="$(count_lines "$SENTINEL_CAPTURE")" + originate bridge_pinned cp5-red >/dev/null || fail "AMI Originate (red) failed" + if red_line="$(wait_new_line "$SENTINEL_CAPTURE" "$sprev" "own=$A" 15)"; then + # Confirm the follow-to-B assertion would have FAILED: nothing reached B. + bnow="$(count_lines "$BRIDGE_CAPTURE")" + if (( bnow > bprev )); then + fail "RED: unexpected datagram at B during the pinned-static call" + fi + say "RED captured: defeated routing misrouted the INVITE to STALE A; follow-to-B assertion RED. [$red_line]" + RED=PASS_AS_RED + else + RED=FAIL + fail "RED negative control did NOT fire: sentinel saw no INVITE at A (A-detection is broken / false-green risk)" + fi +fi + +# ============================================================================ +# Proof + verdict +# ============================================================================ +{ + echo "CP5 container-restart REGISTER 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 "rustisk container IP (fixed): $RUSTISK_IP" + echo "bridge IP A (pre-restart, runtime-read): $A" + echo "bridge IP B (post-restart, runtime-read): $B" + echo "A != B: $([[ "$A" != "$B" ]] && echo yes || echo NO)" + echo + echo "GREEN-A (INVITE arrives at registered A): $GREEN_A" + echo "GREEN-B (INVITE follows to B, stale A silent): $GREEN_B" + echo "RED (static-pin defeats routing -> A caught): $RED" + echo + echo "--- bridge receiver-side INVITE captures ---" + cat "$BRIDGE_CAPTURE" 2>/dev/null || true + echo "--- sentinel (stale-A) INVITE captures ---" + cat "$SENTINEL_CAPTURE" 2>/dev/null || true + echo "--- rustisk registrar binding events ---" + docker logs "$RUSTISK_CONTAINER" 2>&1 | grep -Ei 'Contact registered|Contact removed|Too many contacts|Handled REGISTER' || true +} >"$PROOF" + +say '' +say '================ VERDICT ================' +cat "$PROOF" + +verdict_ok=1 +[[ "$GREEN_A" == "PASS" ]] || verdict_ok=0 +if [[ "$CASE" == "all" || "$CASE" == "green" ]]; then [[ "$GREEN_B" == "PASS" ]] || verdict_ok=0; fi +if [[ "$CASE" == "all" || "$CASE" == "red" ]]; then [[ "$RED" == "PASS_AS_RED" ]] || verdict_ok=0; fi + +if (( verdict_ok == 1 )); then + say '' + say "PASS: CP5 dynamic REGISTER follows a real container restart A=$A -> B=$B (receiver-side); RED negative control captured." + say "Proof: $PROOF" + exit 0 +else + fail "CP5 harness verdict FAILED (see verdict above)" +fi diff --git a/tests/cp5-register-restart/sip_agent.py b/tests/cp5-register-restart/sip_agent.py new file mode 100755 index 0000000..80a097f --- /dev/null +++ b/tests/cp5-register-restart/sip_agent.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Minimal UDP SIP agent for the CP5 container-restart REGISTER harness. + +Two roles, one script (stdlib only, runs in the pinned python image): + + --role bridge Digest-REGISTERs to rustisk advertising THIS container's IP + (401 -> digest -> 200), then serves: every inbound INVITE + datagram is captured RECEIVER-SIDE (own arrival, not a + rustisk TX log) to --capture, and answered 100 -> 486 so the + rustisk INVITE client transaction terminates cleanly. + + --role sentinel Does NOT register. Binds the (now-vacated) address A that a + restarted bridge used to hold, and captures ANY INVITE that + still arrives there. A datagram here after the bridge moved + to B is a stale-route hit. + +The agent derives its own routable IP by route-selecting toward the registrar +(a connected UDP socket performs selection without sending a packet), so the +harness never hardcodes the Docker-assigned address. +""" + +import argparse +import hashlib +import os +import random +import re +import socket +import sys +import time + +SIP_PORT = 5060 + + +def log(msg): + sys.stderr.write("[sip_agent] " + msg + "\n") + sys.stderr.flush() + + +def own_ip_toward(registrar_ip): + """Return the local IP the kernel would use to reach the registrar.""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect((registrar_ip, 15060)) + return s.getsockname()[0] + finally: + s.close() + + +def rand_hex(n=16): + return "".join(random.choice("0123456789abcdef") for _ in range(n)) + + +def md5_hex(s): + return hashlib.md5(s.encode("utf-8")).hexdigest() + + +def parse_challenge(www): + """Parse a Digest WWW-Authenticate value into a dict of params.""" + value = www.strip() + if value.lower().startswith("digest"): + value = value[len("digest"):].strip() + params = {} + for m in re.finditer(r'(\w+)\s*=\s*(?:"([^"]*)"|([^,]+))', value): + key = m.group(1).lower() + params[key] = (m.group(2) if m.group(2) is not None else m.group(3)).strip() + return params + + +def build_authorization(username, password, method, uri, ch): + realm = ch.get("realm", "") + nonce = ch.get("nonce", "") + qop = ch.get("qop") + algorithm = ch.get("algorithm", "MD5") + opaque = ch.get("opaque") + ha1 = md5_hex("%s:%s:%s" % (username, realm, password)) + ha2 = md5_hex("%s:%s" % (method, uri)) + parts = [ + 'username="%s"' % username, + 'realm="%s"' % realm, + 'nonce="%s"' % nonce, + 'uri="%s"' % uri, + ] + if qop and "auth" in qop: + cnonce = rand_hex() + nc = "00000001" + response = md5_hex("%s:%s:%s:%s:auth:%s" % (ha1, nonce, nc, cnonce, ha2)) + parts.append('response="%s"' % response) + parts.append("algorithm=%s" % algorithm) + parts.append("qop=auth") + parts.append("nc=%s" % nc) + parts.append('cnonce="%s"' % cnonce) + else: + response = md5_hex("%s:%s:%s" % (ha1, nonce, ha2)) + parts.append('response="%s"' % response) + parts.append("algorithm=%s" % algorithm) + if opaque: + parts.append('opaque="%s"' % opaque) + return "Digest " + ", ".join(parts) + + +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): + vals = get_headers(text, name) + return vals[0] if vals else None + + +def build_register(aor, registrar_ip, own, call_id, cseq, authorization=None): + reg_uri = "sip:%s" % registrar_ip + lines = [ + "REGISTER %s SIP/2.0" % reg_uri, + "Via: SIP/2.0/UDP %s:%d;rport;branch=z9hG4bK%s" % (own, SIP_PORT, rand_hex(12)), + "Max-Forwards: 70", + "From: ;tag=%s" % (aor, registrar_ip, rand_hex(8)), + "To: " % (aor, registrar_ip), + "Call-ID: %s" % call_id, + "CSeq: %d REGISTER" % cseq, + "Contact: " % (aor, own, SIP_PORT), + ] + if authorization: + lines.append("Authorization: %s" % authorization) + lines.append("Expires: 3600") + lines.append("Content-Length: 0") + lines.append("") + lines.append("") + return ("\r\n".join(lines)).encode("utf-8") + + +def status_of(text): + first = text.split("\r\n", 1)[0] + m = re.match(r"SIP/2\.0\s+(\d{3})", first) + return int(m.group(1)) if m else None + + +def do_register(sock, aor, registrar_ip, username, password, own, status_path): + """Full digest REGISTER (401 -> digest -> 200), retrying until rustisk is up.""" + reg_uri = "sip:%s" % registrar_ip + dst = (registrar_ip, 15060) + deadline = time.time() + 90 + attempt = 0 + while time.time() < deadline: + attempt += 1 + call_id = "%s-%d" % (rand_hex(10), attempt) + # 1. unauthenticated REGISTER -> expect 401 + sock.sendto(build_register(aor, registrar_ip, own, call_id, 1), dst) + ch = None + t = time.time() + 2 + while time.time() < t: + try: + sock.settimeout(1.0) + data, _src = sock.recvfrom(8192) + except socket.timeout: + continue + text = data.decode("utf-8", "replace") + st = status_of(text) + if st == 401: + www = get_header(text, "WWW-Authenticate") + if www: + ch = parse_challenge(www) + break + if st == 200: + # already bound (rare); treat as success + _mark_registered(status_path, own) + return True + if ch is None: + time.sleep(1.0) + continue + # 2. authenticated REGISTER -> expect 200 + auth = build_authorization(username, password, "REGISTER", reg_uri, ch) + sock.sendto(build_register(aor, registrar_ip, own, call_id, 2, auth), dst) + t = time.time() + 3 + while time.time() < t: + try: + sock.settimeout(1.0) + data, _src = sock.recvfrom(8192) + except socket.timeout: + continue + text = data.decode("utf-8", "replace") + st = status_of(text) + if st == 200: + _mark_registered(status_path, own) + log("REGISTERED aor=%s own=%s (attempt %d)" % (aor, own, attempt)) + return True + if st in (401, 403): + log("REGISTER rejected status=%s (attempt %d)" % (st, attempt)) + break + time.sleep(1.0) + return False + + +def _mark_registered(status_path, own): + if status_path: + with open(status_path, "a") as f: + f.write("REGISTERED own=%s ts=%.3f\n" % (own, time.time())) + f.flush() + + +def build_response(req_text, code, reason, own): + """Build a SIP response echoing dialog-identifying headers from req_text.""" + 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 "tag=" not in to: + to = to + ";tag=%s" % rand_hex(8) + 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("Content-Length: 0") + lines.append("") + lines.append("") + return ("\r\n".join(lines)).encode("utf-8") + + +def serve(sock, own, role, capture_path): + """Capture inbound INVITEs receiver-side; answer 100 then 486.""" + tag = "INVITE" if role == "bridge" else "STRAY_INVITE" + while True: + try: + sock.settimeout(1.0) + 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 "): + call_id = get_header(text, "Call-ID") or "?" + ruri = first.split(" ", 2)[1] if len(first.split(" ")) >= 2 else "?" + line = "%s role=%s own=%s src=%s:%d ruri=%s callid=%s ts=%.3f\n" % ( + tag, role, own, src[0], src[1], ruri, call_id, time.time()) + with open(capture_path, "a") as f: + f.write(line) + f.flush() + log(line.strip()) + # Answer so the client INVITE transaction terminates cleanly. + sock.sendto(build_response(text, 100, "Trying", own), src) + sock.sendto(build_response(text, 486, "Busy Here", own), src) + elif first.startswith("ACK ") or first.startswith("BYE ") or first.startswith("CANCEL "): + if first.startswith("BYE ") or first.startswith("CANCEL "): + sock.sendto(build_response(text, 200, "OK", own), src) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--role", choices=["bridge", "sentinel"], required=True) + ap.add_argument("--registrar", required=True, help="rustisk container IP") + ap.add_argument("--aor", default="bridge") + ap.add_argument("--user", default="bridge") + ap.add_argument("--password", default="bridgepass") + ap.add_argument("--capture", required=True) + ap.add_argument("--status", default="") + args = ap.parse_args() + + own = own_ip_toward(args.registrar) + 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)) + log("role=%s own=%s registrar=%s" % (args.role, own, args.registrar)) + + if args.role == "bridge": + ok = do_register(sock, args.aor, args.registrar, args.user, args.password, + own, args.status) + if not ok: + log("FATAL: registration never succeeded") + sys.exit(1) + else: + # sentinel: announce readiness on the vacated address + _mark_registered(args.status, own) if args.status else None + log("SENTINEL holding own=%s" % own) + + serve(sock, own, args.role, args.capture) + + +if __name__ == "__main__": + main() From 681fac67fc1deb5e0129f4fdb51e21956a6b1815 Mon Sep 17 00:00:00 2001 From: Repin Agent Date: Fri, 17 Jul 2026 05:44:26 -0600 Subject: [PATCH 2/2] test(sip): harden CP5 harness against false-green (codex review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sip_agent.py: a 200 to the UNAUTHENTICATED REGISTER is now a hard failure (exit 3), not success — a regression that bound an unauthenticated contact would otherwise green-light the digest-auth proof. - run.sh: validate CP5_CASE up front (all|green|red) so a typo cannot skip the case-gated assertions and still exit 0. - pjsip.conf/run.sh: give bridge_pinned a distinct contact user (sip:pinned@A) and correlate the RED capture on that Request-URI, so no stray 'bridge' datagram can false-pass RED. - run.sh: harden reap_container (retry host-PID acquisition, timeout-wrap every docker op) and raise a loud CLEANUP WARNING on any leaked container/network. - ami_originate.py: require the Originate-specific 'successfully queued'; a bare 'Success' (also emitted by Login) no longer counts as Originate success. - README: correct 'shred' wording and document the distinct pinned user. --- tests/cp5-register-restart/README.md | 15 +++--- tests/cp5-register-restart/ami_originate.py | 5 +- .../config/pjsip.conf.tmpl | 5 +- tests/cp5-register-restart/run.sh | 46 ++++++++++++++----- tests/cp5-register-restart/sip_agent.py | 11 +++-- 5 files changed, 58 insertions(+), 24 deletions(-) diff --git a/tests/cp5-register-restart/README.md b/tests/cp5-register-restart/README.md index 136412a..9c0b01d 100644 --- a/tests/cp5-register-restart/README.md +++ b/tests/cp5-register-restart/README.md @@ -36,12 +36,13 @@ bridge a new IP.** The harness watches the INVITE *arrive* on the bridge/sentine route. 4. **RED (captured negative control).** Routing is defeated by a **static contact pinned to A** (`bridge_pinned`, whose AoR carries `contact = - sip:bridge@A:5060` and never re-registers). `Dial(PJSIP/bridge_pinned)` - misroutes to stale A; the sentinel catches the INVITE and the follow-to-B - assertion goes **RED**. This proves the A-detection is real — the harness can - fail — so a green GREEN-B is meaningful. (The complementary - `best_contact` oldest-wins RED is covered directly in-process by - `e2e_dynamic_register.rs`.) + sip:pinned@A:5060` — a distinct user-part — and never re-registers). + `Dial(PJSIP/bridge_pinned)` misroutes to stale A; the sentinel catches the + INVITE (correlated by its `sip:pinned@A` Request-URI so no stray `bridge` + datagram can be mistaken for it) and the follow-to-B assertion goes **RED**. + This proves the A-detection is real — the harness can fail — so a green + GREEN-B is meaningful. (The complementary `best_contact` oldest-wins RED is + covered directly in-process by `e2e_dynamic_register.rs`.) A and B are **read at runtime** (`docker inspect`), never hardcoded. rustisk's own address is fixed and kept out of the dynamic IP pool via `--ip-range`. @@ -65,7 +66,7 @@ own address is fixed and kept out of the dynamic IP pool via `--ip-range`. ## Notes / environment - rustisk fails closed without a mounted PIN secret, so the harness generates a - throwaway random six-digit **test** PIN, mounts it read-only, and shreds it on + throwaway random six-digit **test** PIN, mounts it read-only, and removes it on exit. No PIN value is logged or committed. - tron's docker is userns-remapped: the daemon cannot `SIGKILL` a container running as our uid (`docker stop`/`rm -f` hang). Containers run diff --git a/tests/cp5-register-restart/ami_originate.py b/tests/cp5-register-restart/ami_originate.py index a2e8790..9444629 100755 --- a/tests/cp5-register-restart/ami_originate.py +++ b/tests/cp5-register-restart/ami_originate.py @@ -53,7 +53,10 @@ def main(): text = response.decode("utf-8", "replace") sys.stdout.write(text) - if "successfully queued" not in text and "Success" not in 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) diff --git a/tests/cp5-register-restart/config/pjsip.conf.tmpl b/tests/cp5-register-restart/config/pjsip.conf.tmpl index 42553dc..7a53b83 100644 --- a/tests/cp5-register-restart/config/pjsip.conf.tmpl +++ b/tests/cp5-register-restart/config/pjsip.conf.tmpl @@ -52,4 +52,7 @@ aors = bridge_pinned_aor [bridge_pinned_aor] type = aor -contact = sip:bridge@@PINNED_A@:5060 +; Distinct user-part ("pinned", not "bridge") so the RED INVITE's Request-URI is +; unambiguously the pinned-static call and cannot be conflated with any stray +; `bridge` datagram when the sentinel correlates the capture. +contact = sip:pinned@@PINNED_A@:5060 diff --git a/tests/cp5-register-restart/run.sh b/tests/cp5-register-restart/run.sh index 8624ab5..bb2ffe0 100755 --- a/tests/cp5-register-restart/run.sh +++ b/tests/cp5-register-restart/run.sh @@ -20,7 +20,7 @@ # # Isolated Docker only: it never touches the live voice stack, Helm, k8s, the # carrier trunk, or the real PIN. A throwaway six-digit TEST pin is generated, -# mounted read-only (rustisk fails closed without one), and shredded on exit. +# mounted read-only (rustisk fails closed without one), and removed on exit. # # tests/cp5-register-restart/run.sh # @@ -63,9 +63,15 @@ CASE="${CP5_CASE:-all}" # denied'). The containers run `--user $(id -u)`, so WE own their host PID and # can signal it. Reap = kill the host PID, then rm. reap_container() { - local c="$1" hp + local c="$1" hp i docker inspect "$c" >/dev/null 2>&1 || return 0 - hp="$(docker inspect -f '{{.State.Pid}}' "$c" 2>/dev/null || true)" + # The host PID can read empty for a container still mid-start — retry. + 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 @@ -74,21 +80,30 @@ reap_container() { timeout 3 docker wait "$c" >/dev/null 2>&1 || true fi fi - docker rm -f "$c" >/dev/null 2>&1 || true - # Wait for the name/endpoint to be released. + timeout 10 docker rm -f "$c" >/dev/null 2>&1 || true + # Wait for the name/endpoint to be released; report a leak if it persists. for _ in $(seq 1 20); do docker inspect "$c" >/dev/null 2>&1 || return 0; sleep 0.25; done + return 1 } cleanup() { # Snapshot rustisk logs before teardown (best-effort). docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true - reap_container "$SENTINEL_CONTAINER" - reap_container "$BRIDGE_CONTAINER" - reap_container "$RUSTISK_CONTAINER" - docker network rm "$NET" >/dev/null 2>&1 || true + local leaked=0 + reap_container "$SENTINEL_CONTAINER" || leaked=1 + reap_container "$BRIDGE_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/cp5-pin-secret.* ]]; then rm -rf "$SECRET_DIR" fi + # An EXIT trap cannot flip an already-set exit code, but a docker leak must + # be impossible to miss (it can wedge tron). + 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 (kill host PID, docker rm -f, docker network rm %s)\n' "$NET" >&2 + fi } trap cleanup EXIT trap 'exit 130' INT @@ -169,13 +184,20 @@ require_command docker require_command python3 require_command cargo +# Reject an unknown case up front: an unvalidated selector would skip the case- +# gated assertions and still exit 0 with a full-acceptance message (false pass). +case "$CASE" in + all|green|red) ;; + *) fail "invalid CP5_CASE='$CASE' (expected: all|green|red)" ;; +esac + say '=== CP5 container-restart REGISTER harness ===' rm -rf "$RUNTIME_DIR" mkdir -p "$CONFIG_DIR" "$RUN_DIR" : >"$BRIDGE_CAPTURE"; : >"$SENTINEL_CAPTURE"; : >"$BRIDGE_STATUS"; : >"$SENTINEL_STATUS" # Throwaway random TEST pin (rustisk fails closed without a mounted secret). -# Generated locally, mounted read-only, never logged, shredded on exit — a pure +# Generated locally, mounted read-only, never logged, removed on exit — a pure # test value, unrelated to any production secret. SECRET_DIR="$(mktemp -d /mnt/data/herodevs-agents/cp5-pin-secret.XXXXXX)" chmod 700 "$SECRET_DIR" @@ -326,7 +348,9 @@ if [[ "$CASE" == "all" || "$CASE" == "red" ]]; then bprev="$(count_lines "$BRIDGE_CAPTURE")" sprev="$(count_lines "$SENTINEL_CAPTURE")" originate bridge_pinned cp5-red >/dev/null || fail "AMI Originate (red) failed" - if red_line="$(wait_new_line "$SENTINEL_CAPTURE" "$sprev" "own=$A" 15)"; then + # Correlate on the pinned Request-URI so a stray `bridge` datagram at A cannot + # false-pass RED. + if red_line="$(wait_new_line "$SENTINEL_CAPTURE" "$sprev" "ruri=sip:pinned@$A" 15)"; then # Confirm the follow-to-B assertion would have FAILED: nothing reached B. bnow="$(count_lines "$BRIDGE_CAPTURE")" if (( bnow > bprev )); then diff --git a/tests/cp5-register-restart/sip_agent.py b/tests/cp5-register-restart/sip_agent.py index 80a097f..9430c03 100755 --- a/tests/cp5-register-restart/sip_agent.py +++ b/tests/cp5-register-restart/sip_agent.py @@ -151,7 +151,10 @@ def do_register(sock, aor, registrar_ip, username, password, own, status_path): while time.time() < deadline: attempt += 1 call_id = "%s-%d" % (rand_hex(10), attempt) - # 1. unauthenticated REGISTER -> expect 401 + # 1. unauthenticated REGISTER -> MUST be challenged 401. A 200 here means + # rustisk bound an UNAUTHENTICATED contact — an auth regression that + # would otherwise false-green the whole "digest-authenticated REGISTER" + # proof. Fail hard. sock.sendto(build_register(aor, registrar_ip, own, call_id, 1), dst) ch = None t = time.time() + 2 @@ -169,9 +172,9 @@ def do_register(sock, aor, registrar_ip, username, password, own, status_path): ch = parse_challenge(www) break if st == 200: - # already bound (rare); treat as success - _mark_registered(status_path, own) - return True + log("FATAL: unauthenticated REGISTER was accepted (200) without a " + "digest challenge — auth regression") + sys.exit(3) if ch is None: time.sleep(1.0) continue