diff --git a/tests/lab/README.md b/tests/lab/README.md index 5fe0a15..5946f85 100644 --- a/tests/lab/README.md +++ b/tests/lab/README.md @@ -63,6 +63,72 @@ GATEWAY_SIP_PORT=21062 # must differ from the Asterisk port > engine is assigned — `main.py`'s lifespan calls `build_sip_engine()` after > construction. A harness that skips that step silently tests the mock. +## The softphone (transfer target) + +**Asterisk is the registrar for devices, not Hold Slayer.** The gateway reaches +a desk phone by dialling extension `2001`, which Asterisk routes to whatever +has registered as `softphone`. This deliberately avoids Hold Slayer's own SIP +listener, which answers `200 OK` to any REGISTER with no digest challenge. + +The `pjsua` CLI built alongside the Python bindings is the test device — same +library stack as the gateway, no extra dependency. It needs an RPATH patch like +the bindings did: + +```bash +cp ~/src/pjproject/pjsip-apps/bin/pjsua-x86_64-pc-linux-gnu ~/.local/bin/pjsua +patchelf --set-rpath $HOME/.local/lib ~/.local/bin/pjsua +``` + +Register it (config file avoids shell-quoting pain): + +```bash +cat > softphone.cfg <<'EOF' +--null-audio +--auto-answer=200 +--max-calls=4 +--local-port=21070 +--id=sip:softphone@127.0.0.1 +--registrar=sip:127.0.0.1:21061 +--realm=asterisk +--username=softphone +--password=labphone +--log-level=3 +EOF + +# pjsua is an interactive console app: it exits ~8s after start if stdin is +# closed or /dev/null. Hold a fifo open on stdin — `script -qfc` and +# `setsid sp.fifo; pjsua --config-file softphone.cfg <&3 >softphone.log 2>&1' & +``` + +Verify — **check the port is actually bound**, not just that Asterisk holds a +contact, since a stale registration outlives the process: + +```bash +ss -lnup | grep 21070 # must be listening +docker compose -f docker-compose.lab.yml exec asterisk \ + asterisk -rx "pjsip show contacts" # must show softphone +``` + +Then place a call to `2001`. Both legs should show `Up` under one bridge id: + +```bash +docker compose -f docker-compose.lab.yml exec asterisk \ + asterisk -rx "core show channels concise" +``` + +> **`--realm=asterisk`, not `--realm='*'`** — the wildcard fails with +> `PJSIP_EFAILEDCREDENTIAL` against Asterisk's digest challenge. + +> **Qualify is off** for this AOR (`qualify_frequency = 0`): the pjsua console +> does not answer `OPTIONS`, so polling marks a working softphone `Unavail` and +> the dialplan refuses to ring it. The `2001` guard therefore tests +> `PJSIP_AOR(softphone,contact)` rather than `DEVICE_STATE`. A real hardphone +> answers OPTIONS and can have qualify re-enabled. + ## Useful commands ```bash diff --git a/tests/lab/dialplan/extensions.conf b/tests/lab/dialplan/extensions.conf index 1092a68..e03b793 100644 --- a/tests/lab/dialplan/extensions.conf +++ b/tests/lab/dialplan/extensions.conf @@ -128,6 +128,23 @@ exten => 1008,1,NoOp(LAB 1008: silence) same => n,Wait(40) same => n,Hangup() +; --- 2001: ring the registered softphone ---------------------------------- +; The transfer target. Asterisk is the registrar for devices, so the gateway +; reaches a desk phone by dialling this rather than by registering it itself. +; Fails fast when nothing is registered — a silent 30s ring would look like a +; gateway bug rather than an absent softphone. +exten => 2001,1,NoOp(LAB 2001: ring softphone) + ; Count registered contacts rather than DEVICE_STATE: device state follows + ; the OPTIONS qualify, which is off for this AOR (the pjsua CLI does not + ; answer OPTIONS), so a registered softphone would still read UNAVAILABLE. + same => n,GotoIf($[${PJSIP_AOR(softphone,contact)} = ""]?nodevice) + same => n,Dial(PJSIP/softphone,30) + same => n,Hangup() + same => n(nodevice),NoOp(LAB 2001: no softphone registered) + same => n,Answer() + same => n,Playback(lab-speech) + same => n,Hangup() + ; --- echo test ------------------------------------------------------------ ; Not a scenario — a debugging aid. Echoes audio back so you can confirm ; bidirectional RTP by ear when something looks wrong. diff --git a/tests/lab/dialplan/pjsip.conf b/tests/lab/dialplan/pjsip.conf index 168bd0f..881ede8 100644 --- a/tests/lab/dialplan/pjsip.conf +++ b/tests/lab/dialplan/pjsip.conf @@ -34,14 +34,21 @@ local_net = {{ asterisk_local_net }} ; --------------------------------------------------------------------------- ; Hold Slayer authenticates as this endpoint to place calls into the lab. -; Identify the endpoint by source address. Asterisk's default matching uses -; the From-header domain, which Hold Slayer populates from its SIP bind -; address (0.0.0.0 on a wildcard bind) — never a value Asterisk can match. -; Matching on where the packet actually came from sidesteps that. +; Identify the endpoint by source address *and port*. Asterisk's default +; matching uses the From-header domain, which Hold Slayer populates from its +; SIP bind address (0.0.0.0 on a wildcard bind) — never a value Asterisk can +; match. Matching on where the packet came from sidesteps that. +; +; The port is essential when the softphone runs on the same host: a +; host-only match claims *every* packet from that address, so the +; softphone's REGISTER would be attributed to this endpoint and checked +; against the gateway's password ("Failed to authenticate", confusingly). +; Endpoints that authenticate by username (the softphone) must not be +; covered by an identify block. [hold-slayer] type = identify endpoint = hold-slayer -match = {{ asterisk_match_host }} +match = {{ asterisk_match_host }}:{{ asterisk_gateway_port }} [hold-slayer] type = endpoint @@ -68,6 +75,56 @@ auth_type = userpass username = {{ asterisk_sip_username }} password = {{ asterisk_sip_password }} +; --------------------------------------------------------------------------- +; Softphone endpoint — the transfer target +; --------------------------------------------------------------------------- +; Asterisk is the registrar for devices, not Hold Slayer. A softphone REGISTERs +; here and the gateway transfers a live call to it by dialling extension 2001. +; +; Deliberate: Hold Slayer's own SIP listener answers 200 OK to any REGISTER +; with no digest challenge, so anything on the network could register as a +; device and receive transferred calls. Keeping registration in Asterisk means +; the lab does not depend on that path, and the softphone is authenticated. +; +; Test with the pjsua CLI built alongside the Python bindings: +; pjsua --null-audio --auto-answer=200 \ +; --id=sip:softphone@ \ +; --registrar=sip::21061 \ +; --realm='*' --username=softphone --password= \ +; --local-port= + +[softphone] +type = endpoint +context = hold-slayer-lab +disallow = all +allow = ulaw +allow = alaw +auth = softphone-auth +aors = softphone +dtmf_mode = rfc4733 +direct_media = no +force_rport = yes +rewrite_contact = yes +rtp_symmetric = yes + +[softphone-auth] +type = auth +auth_type = userpass +username = {{ asterisk_softphone_username }} +password = {{ asterisk_softphone_password }} + +[softphone] +type = aor +; The device's contact is learned from its REGISTER rather than configured — +; a softphone's port is not known in advance. +max_contacts = 1 +remove_existing = yes +; No qualify: the pjsua CLI does not answer OPTIONS while sitting at its +; console prompt, so polling marks a perfectly working softphone Unavail and +; the dialplan refuses to ring it. Registration itself is the liveness signal +; here. A real hardphone answers OPTIONS and can have qualify re-enabled. +qualify_frequency = 0 + [hold-slayer] type = aor max_contacts = 2