test(lab): add Asterisk lab — a fake PSTN for media validation
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m12s

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:
2026-07-29 06:06:14 -04:00
parent 204203e3b0
commit c00cf02676
11 changed files with 848 additions and 0 deletions

3
tests/lab/dialplan/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Rendered from the .conf templates by the local-lab instructions in
# README.md; contains a host-specific IP and the lab password.
*.local.conf

View File

@@ -0,0 +1,24 @@
; Minimal Asterisk core config for the lab.
[directories](!)
astetcdir => /etc/asterisk
astmoddir => /usr/lib/asterisk/modules
astvarlibdir => /var/lib/asterisk
astdbdir => /var/lib/asterisk
astkeydir => /var/lib/asterisk
astdatadir => /var/lib/asterisk
astagidir => /var/lib/asterisk/agi-bin
astspooldir => /var/spool/asterisk
astrundir => /var/run/asterisk
astlogdir => /var/log/asterisk
astsbindir => /usr/sbin
[options]
; Log to stdout so Docker's json-file driver captures it and Alloy ships it.
; A file-based log inside the container would be invisible to Loki.
verbose = 3
debug = 0
nocolor = yes
dumpcore = no
; Never run as root inside the container.
runuser = asterisk
rungroup = asterisk

View 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()

View File

@@ -0,0 +1,8 @@
; Log to stdout only — Docker's json-file driver captures it and the host
; Alloy ships it to Loki as job=<compose project>. Writing to a file inside
; the container would put the logs where nothing can see them.
[general]
dateformat = %F %T
[logfiles]
console => notice,warning,error

View File

@@ -0,0 +1,75 @@
; ---------------------------------------------------------------------------
; Hold Slayer lab — PJSIP configuration
; ---------------------------------------------------------------------------
; SECURITY: this endpoint answers calls. Asterisk's stock examples allow
; anonymous inbound, which is a well-known toll-fraud target. This config
; refuses it: every call must authenticate as the `hold-slayer` endpoint.
;
; There is no PSTN behind this Asterisk — an unauthorised call reaches only
; the lab dialplan and costs nothing. The lock-down is defence in depth and
; so this config is never copied somewhere it would matter.
; ---------------------------------------------------------------------------
[global]
type = global
; Do not fall through to an `anonymous` endpoint for unmatched calls.
; This is the single most important line in the file.
unidentified_request_count = 5
unidentified_request_period = 5
unidentified_request_prune_interval = 30
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:{{ asterisk_sip_port }}
; The address Asterisk advertises in SDP. Without this, containers advertise
; their internal bridge IP and RTP arrives at an unroutable address — the
; classic "call connects but there is no audio" failure.
external_media_address = {{ asterisk_external_ip }}
external_signaling_address = {{ asterisk_external_ip }}
local_net = {{ asterisk_local_net }}
; ---------------------------------------------------------------------------
; Hold Slayer endpoint
; ---------------------------------------------------------------------------
; 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.
[hold-slayer]
type = identify
endpoint = hold-slayer
match = {{ asterisk_match_host }}
[hold-slayer]
type = endpoint
context = hold-slayer-lab
disallow = all
; ulaw first: it is what the PSTN uses, so the lab exercises the same codec
; path a real trunk would. alaw as fallback.
allow = ulaw
allow = alaw
auth = hold-slayer-auth
aors = hold-slayer
; RFC 2833 out-of-band DTMF — what send_dtmf must produce. Setting this
; explicitly (rather than `auto`) means a DTMF failure is a real failure and
; not a negotiation fallback quietly rescuing it.
dtmf_mode = rfc4733
direct_media = no
force_rport = yes
rewrite_contact = yes
rtp_symmetric = yes
[hold-slayer-auth]
type = auth
auth_type = userpass
username = {{ asterisk_sip_username }}
password = {{ asterisk_sip_password }}
[hold-slayer]
type = aor
max_contacts = 2
remove_existing = yes
qualify_frequency = 60

View File

@@ -0,0 +1,9 @@
; RTP media port range for the lab.
;
; 50 ports ≈ 25 concurrent calls — comfortably above Hold Slayer's
; max_concurrent_calls (default 4). The range must match the ports published
; in docker-compose, or media arrives at a port Docker isn't forwarding and
; the call connects with no audio.
[general]
rtpstart = {{ asterisk_rtp_start }}
rtpend = {{ asterisk_rtp_end }}