From c516f659cc8ba764d2112328f425446f01b4c827 Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Wed, 29 Jul 2026 07:57:17 -0400 Subject: [PATCH] feat(lab): add softphone endpoint so device registration can be tested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asterisk is the registrar for devices, not Hold Slayer. A softphone REGISTERs to the lab and the gateway transfers a live call to it by dialling extension 2001. This is 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 exercise or depend on that path, and the device is authenticated. The pjsua CLI built alongside the Python bindings is the test device — same library stack as the gateway, so no new dependency. Verified end to end: a gateway call to 2001 produces two channels Up under one bridge id. Three things that cost time and are now written down: - `--realm=asterisk`, not `--realm='*'`: the wildcard fails against Asterisk's digest challenge with PJSIP_EFAILEDCREDENTIAL. - pjsua is an interactive console app and exits ~8s after start if stdin is closed or /dev/null. `script -qfc` and `setsid --- tests/lab/README.md | 66 +++++++++++++++++++++++++++++ tests/lab/dialplan/extensions.conf | 17 ++++++++ tests/lab/dialplan/pjsip.conf | 67 +++++++++++++++++++++++++++--- 3 files changed, 145 insertions(+), 5 deletions(-) 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