Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions tests/cp5-register-restart/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 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: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`.

## Addresses and IP assignment

- Network: `--internal`, `10.252.<octet>.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 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
`--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`.
65 changes: 65 additions & 0 deletions tests/cp5-register-restart/ami_originate.py
Original file line number Diff line number Diff line change
@@ -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/<ENDPOINT>` 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()
4 changes: 4 additions & 0 deletions tests/cp5-register-restart/config/asterisk.conf.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[directories]
astetcdir = @CONFIG_DIR@
astrundir = @RUN_DIR@
astincludedir = @RUN_DIR@/include
6 changes: 6 additions & 0 deletions tests/cp5-register-restart/config/extensions.conf
Original file line number Diff line number Diff line change
@@ -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/<endpoint>); this context is only the nominal answer target.
[default]
exten => s,1,Answer()
same => n,Hangup()
12 changes: 12 additions & 0 deletions tests/cp5-register-restart/config/manager.conf
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions tests/cp5-register-restart/config/pjsip.conf.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
; 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: <sip:bridge@rustisk>`.
; 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
; 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
3 changes: 3 additions & 0 deletions tests/cp5-register-restart/config/rtp.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[general]
rtpstart = 31000
rtpend = 31040
Loading
Loading