test(lab): add Asterisk lab — a fake PSTN for media validation
An Asterisk instance that answers calls, plays an IVR, holds with music and connects a "human", so the gateway has something real to dial that is not the PSTN: no charges, no strangers, no E911 exposure. Asterisk rather than Kamailio because the unproven risks are media risks. Kamailio is a proxy — it routes signalling and answers nothing, so it would forward the INVITE and find nobody home. Asterisk is a B2BUA: it answers, plays prompts and collects DTMF, which is the hold-slayer scenario itself. Kamailio remains the better model for trunk registration/digest auth later. No application changes are needed to use it. SIP_TRUNK_HOST is just an address, so the production code path runs unmodified — there is no test-only branch anywhere in the gateway. It also means safety is structural: while the trunk points at the lab there is no route to the PSTN at all, an absence of route rather than a policy that could be misconfigured. Nine scenarios (1001-1008 plus an echo test) cover the baseline call, the IVR/DTMF path, hold-then-human, long hold, busy, no-answer, remote hangup and silence. The image ships no sound files, so sounds/generate.py synthesises three fixtures from fixed seeds — byte-identical on every run, which is what makes a classifier regression distinguishable from noise. Verified against AudioClassifier: music→MUSIC 0.85, speech→LIVE_HUMAN 0.75, silence→SILENCE 1.00. The speech formants deliberately avoid the DTMF bands; the first version landed on a valid pair and classified as a keypress. Anonymous inbound calls are refused, and endpoint matching is by source address — Asterisk's default matches the From-header domain, which Hold Slayer populates from its SIP bind address (0.0.0.0 on a wildcard bind). Generated audio and the rendered per-host configs are gitignored: the former is reproducible from a fixed seed, the latter carry a host-specific IP and the lab password. Known limit, documented in the README: MediaPipeline.create_tap is a stub, so the classifier receives no audio on a live call. RTP flows and Asterisk plays audio, but the tap is never fed — the fixture results above were measured by feeding the classifier directly. This blocks scenarios 1002/1003/1004. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
144
tests/lab/dialplan/extensions.conf
Normal file
144
tests/lab/dialplan/extensions.conf
Normal file
@@ -0,0 +1,144 @@
|
||||
; ---------------------------------------------------------------------------
|
||||
; Hold Slayer lab dialplan — a fake bank phone tree
|
||||
; ---------------------------------------------------------------------------
|
||||
; Each extension is one scenario Hold Slayer must handle. Everything here is
|
||||
; deterministic on purpose: real hold music varies per call, so a classifier
|
||||
; regression on the PSTN is indistinguishable from noise. Against a fixed
|
||||
; prompt the answer is binary.
|
||||
;
|
||||
; Hold Slayer dials these as `number` with SIP_TRUNK_HOST pointing here.
|
||||
; ---------------------------------------------------------------------------
|
||||
|
||||
[globals]
|
||||
; Lab-generated audio (tests/lab/sounds/, built by generate.py). The Asterisk
|
||||
; image ships with no sounds at all, and these are synthesised from a fixed
|
||||
; seed so the classifier sees byte-identical input on every run.
|
||||
; lab-speech -> must classify LIVE_HUMAN
|
||||
; lab-music -> must classify MUSIC
|
||||
; lab-silence -> must classify SILENCE
|
||||
GREETING=lab-speech
|
||||
INVALID=lab-speech
|
||||
|
||||
[hold-slayer-lab]
|
||||
|
||||
; --- 1001: immediate answer, speech, hangup -------------------------------
|
||||
; Baseline. Proves INVITE→200→ACK→RTP→BYE and that audio flows both ways.
|
||||
; The classifier should report LIVE_HUMAN throughout.
|
||||
exten => 1001,1,NoOp(LAB 1001: immediate answer)
|
||||
same => n,Answer()
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(${GREETING})
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Wait(20)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1002: IVR menu, branches on DTMF -------------------------------------
|
||||
; THE important one. Proves send_dtmf genuinely emits RFC 2833 and that
|
||||
; Asterisk receives the digits — currently a no-op in MockSIPEngine.
|
||||
; Press 1 → accounts (answers as human). Press 2 → cards (hold, then human).
|
||||
exten => 1002,1,NoOp(LAB 1002: IVR menu)
|
||||
same => n,Answer()
|
||||
same => n,Wait(1)
|
||||
same => n,Set(TRIES=0)
|
||||
same => n(menu),Background(lab-speech)
|
||||
same => n,WaitExten(8)
|
||||
same => n,Set(TRIES=$[${TRIES} + 1])
|
||||
same => n,GotoIf($[${TRIES} < 3]?menu)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Hangup()
|
||||
|
||||
; Option 1 — straight to a "human"
|
||||
exten => 1,1,NoOp(LAB 1002: caller pressed 1 -> accounts)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Wait(15)
|
||||
same => n,Hangup()
|
||||
|
||||
; Option 2 — hold queue, then a "human"
|
||||
exten => 2,1,NoOp(LAB 1002: caller pressed 2 -> cards, hold)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Wait(15)
|
||||
same => n,Hangup()
|
||||
|
||||
exten => i,1,NoOp(LAB 1002: invalid entry)
|
||||
same => n,Playback(${INVALID})
|
||||
same => n,Goto(1002,menu)
|
||||
|
||||
exten => t,1,NoOp(LAB 1002: entry timeout)
|
||||
same => n,Goto(1002,menu)
|
||||
|
||||
; --- 1003: hold music, then a human ---------------------------------------
|
||||
; The whole hold-slayer loop in one call: classify music → stay on hold →
|
||||
; detect the human → ring the owner. 60s of MoH is long enough for several
|
||||
; classifier windows (CLASSIFIER_WINDOW_SECONDS defaults to 3.0).
|
||||
exten => 1003,1,NoOp(LAB 1003: hold then human)
|
||||
same => n,Answer()
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Wait(30)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1004: long hold ------------------------------------------------------
|
||||
; Exercises MAX_HOLD_TIME and HOLD_CHECK_INTERVAL. 10 minutes.
|
||||
exten => 1004,1,NoOp(LAB 1004: long hold)
|
||||
same => n,Answer()
|
||||
same => n,Wait(1)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-music)
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Wait(15)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1005: busy -----------------------------------------------------------
|
||||
; Failure path: the call must be marked FAILED with no stuck leg.
|
||||
exten => 1005,1,NoOp(LAB 1005: busy)
|
||||
same => n,Busy(20)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1006: ring, never answer ---------------------------------------------
|
||||
; Timeout path. Rings for 120s without answering.
|
||||
exten => 1006,1,NoOp(LAB 1006: ring no answer)
|
||||
same => n,Progress()
|
||||
same => n,Wait(120)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1007: answer, then remote hangup after 5s ----------------------------
|
||||
; Proves remote-BYE handling and that the call persists to the DB on hangup.
|
||||
exten => 1007,1,NoOp(LAB 1007: quick remote hangup)
|
||||
same => n,Answer()
|
||||
same => n,Playback(${GREETING})
|
||||
same => n,Wait(5)
|
||||
same => n,Hangup()
|
||||
|
||||
; --- 1008: answer, then silence -------------------------------------------
|
||||
; Classifier SILENCE vs the no-audio case. 45s of nothing.
|
||||
exten => 1008,1,NoOp(LAB 1008: silence)
|
||||
same => n,Answer()
|
||||
same => n,Playback(lab-silence)
|
||||
same => n,Wait(40)
|
||||
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.
|
||||
exten => 1099,1,NoOp(LAB 1099: echo test)
|
||||
same => n,Answer()
|
||||
same => n,Playback(lab-speech)
|
||||
same => n,Echo()
|
||||
same => n,Hangup()
|
||||
|
||||
; Anything else: reject explicitly rather than failing obscurely.
|
||||
exten => _X.,1,NoOp(LAB: unknown extension ${EXTEN})
|
||||
same => n,Answer()
|
||||
same => n,Playback(${INVALID})
|
||||
same => n,Hangup()
|
||||
Reference in New Issue
Block a user