Files
hold-slayer/tests/lab/README.md
Robert Helewka c00cf02676
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
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>
2026-07-29 06:06:14 -04:00

148 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Asterisk lab — a fake PSTN
An Asterisk instance that answers calls, plays an IVR, holds you with music,
and eventually connects a "human". It gives the gateway something real to dial
that is **not** the PSTN: no charges, no strangers, no E911 exposure, and a
deterministic script that makes classifier regressions reproducible.
Design rationale and the Virgo deployment plan:
[docs/asterisk-lab-design.md](../../docs/asterisk-lab-design.md).
> This lab found five bugs in `SippyEngine` on its first call — the engine had
> never successfully placed one. Everything below runs against the real
> `SippyEngine`, never `MockSIPEngine`, which is the entire point.
---
## Run it
```bash
cd tests/lab
# 1. Generate the audio fixtures (the image ships with NO sound files).
python sounds/generate.py
# 2. Render the local configs. They carry a host-specific IP and the lab
# password, so they are gitignored — regenerate them per machine.
cd dialplan
LOCALIP=$(ip route get 1.1.1.1 | grep -oP '(?<=src\s)\d+(\.\d+){3}')
sed -e "s/{{ asterisk_sip_port }}/21061/" \
-e "s/{{ asterisk_external_ip }}/$LOCALIP/" \
-e "s#{{ asterisk_local_net }}#10.10.0.0/24#" \
-e "s/{{ asterisk_match_host }}/127.0.0.1/" \
-e "s/{{ asterisk_sip_username }}/holdslayer/" \
-e "s/{{ asterisk_sip_password }}/labpassword/" \
pjsip.conf > pjsip.local.conf
sed -e "s/{{ asterisk_rtp_start }}/21100/" \
-e "s/{{ asterisk_rtp_end }}/21149/" \
rtp.conf > rtp.local.conf
cd ..
# 3. Start it.
docker compose -f docker-compose.lab.yml up -d
```
Point Hold Slayer at it — no code changes, no test-only branch. `make_call`
builds `sip:{number}@{trunk_host}:{trunk_port}`, so the lab is just an address:
```bash
USE_MOCK_SIP=false
SIP_TRUNK_HOST=127.0.0.1
SIP_TRUNK_PORT=21061
SIP_TRUNK_USERNAME=holdslayer
SIP_TRUNK_PASSWORD=labpassword
SIP_TRUNK_DID=+15550000000
GATEWAY_SIP_PORT=21062 # must differ from the Asterisk port
```
> **The repo's own `.env` sets `USE_MOCK_SIP=true`** and a placeholder trunk
> host, and pydantic-settings lets `.env` win over the process environment. If
> the engine reports `MockSIPEngine` despite the above, that is why.
>
> `AIPSTNGateway(settings=...)` also defaults to `MockSIPEngine` unless an
> engine is assigned — `main.py`'s lifespan calls `build_sip_engine()` after
> construction. A harness that skips that step silently tests the mock.
## Useful commands
```bash
docker compose -f docker-compose.lab.yml exec asterisk asterisk -rvvv # CLI
docker compose -f docker-compose.lab.yml logs -f asterisk # logs
docker compose -f docker-compose.lab.yml exec asterisk \
asterisk -rx "pjsip set logger on" # SIP trace
```
---
## Scenarios
Hold Slayer dials these as `number`.
| Ext | Scenario | Proves |
|---|---|---|
| `1001` | Answers, speech, hangs up | Baseline: INVITE→200→ACK→RTP→BYE, audio both ways |
| `1002` | IVR menu, branches on DTMF | `send_dtmf` really emits RFC 2833 and Asterisk receives it |
| `1003` | Hold music, then a human | The hold-slayer loop: music → wait → human → ring owner |
| `1004` | Long hold (~10 min) | `MAX_HOLD_TIME`, `HOLD_CHECK_INTERVAL` |
| `1005` | Busy | Failure path: call marked `FAILED`, no stuck leg |
| `1006` | Rings, never answers | Timeout path |
| `1007` | Answers, hangs up after 5s | Remote BYE, DB persistence on hangup |
| `1008` | Answers, then silence | Classifier `SILENCE` vs. no-audio |
| `1099` | Echo test | Debugging aid — confirm bidirectional RTP by ear |
## Audio fixtures
The Asterisk image ships **no sound files**, and the design calls for
deterministic audio: real hold music varies per call, so a classifier
regression on the PSTN is indistinguishable from noise. `sounds/generate.py`
synthesises three fixtures from fixed seeds — byte-identical every run.
Verified against `AudioClassifier` (16 kHz):
| Fixture | Classifies as | Confidence |
|---|---|---|
| `lab-music.sln` | `MUSIC` | 0.85 |
| `lab-speech.sln` | `LIVE_HUMAN` | 0.75 |
| `lab-silence.sln` | `SILENCE` | 1.00 |
Format is 8 kHz 16-bit mono signed-linear (`.sln`) — Asterisk's native
telephony rate, played without transcoding.
> The speech fixture's formants deliberately avoid the DTMF bands (rows
> 697941 Hz, columns 12091633 Hz). The first version landed on a valid
> DTMF pair and the whole utterance classified as a keypress.
---
## Known limits
- **The classifier receives nothing on a live call.**
`MediaPipeline.create_tap` is a stub — it logs `🎤 Audio tap created` and
returns a tap that is never fed (`core/media_pipeline.py`, and the same at
stream creation). RTP flows and Asterisk plays audio, but nothing reaches
the classifier. The table above was measured by feeding the fixtures
directly. **This blocks the hold-slayer scenarios (1002/1003/1004).**
- **Not real PSTN audio** — no transcoding artefacts, packet loss, jitter, or
carrier-side DTMF mangling. Asterisk is clean; the PSTN is not.
- **Not real IVR behaviour** — this dialplan is what we imagine a bank sounds
like. Real trees are longer, noisier, and interrupt.
- **Not trunk registration against a real ITSP** — `_register_trunk()` works
against Asterisk, but carrier quirks are their own phase.
## Security
`pjsip.conf` refuses anonymous inbound calls: every call must authenticate as
the `hold-slayer` endpoint. Asterisk's stock examples allow anonymous calls and
are a well-known toll-fraud target — there is no PSTN behind this instance, so
an unauthorised call reaches only the dialplan, but the lock-down keeps this
config safe to copy.
Endpoint matching is by **source address** (`type=identify`). 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, which never matches.
> **Separate, pre-existing:** Hold Slayer's own SIP listener answers `200 OK`
> to any REGISTER with no digest challenge. Fine on loopback; it must be
> resolved before the gateway binds a LAN interface, or any host on the
> network can register as a device and receive transferred calls.