Files
hold-slayer/docs/asterisk-lab-design.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

294 lines
14 KiB
Markdown
Raw Permalink 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 — design
A **fake PSTN** for Hold Slayer: an Asterisk instance in Virgo Dev that answers
calls, plays an IVR, holds you in a queue 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.
Status: **design, not built.** Nothing here has been deployed.
---
## Why Asterisk and not Kamailio
Kamailio is a SIP **proxy** — it routes signaling and does not answer calls or
handle media. The risks that remain unproven in Hold Slayer are mostly *media*
risks: `send_dtmf` has only ever run against `MockSIPEngine` (a no-op), the
audio classifier has never seen real RTP, and the PJSUA2 pipeline built in Phase
1b has never carried a packet. A proxy forwards the INVITE and finds nobody
home, so it exercises none of that.
Asterisk is a B2BUA: it answers, plays prompts, collects DTMF, and can hold a
call in a queue with music. That is precisely the hold-slayer scenario, so it is
the right primary target.
Kamailio still has a place — **later, and narrowly**. It models a real ITSP's
registration/digest-auth behaviour better than Asterisk does, so it is the right
tool for exercising `_register_trunk()` ([core/sippy_engine.py:495](../core/sippy_engine.py#L495))
in isolation. It is deliberately *not* in scope for this lab.
```
Phase 2/3 (this lab) Phase 4a (optional) Phase 4b
Asterisk IVR + media → Kamailio registration → real PSTN, one call
```
---
## The one thing that makes this work without code changes
`SIP_TRUNK_HOST` is just an address. `SippyEngine.make_call()` builds
`sip:{number}@{trunk_host}:{trunk_port}`
([core/sippy_engine.py:568](../core/sippy_engine.py#L568)) and registers against
whatever host it is given. Point it at Asterisk and Hold Slayer dials it exactly
as it would dial a real provider.
**There is no test-only branch, no mock, no `if lab:` anywhere.** The code path
under test is the production code path. That is the entire value of this
approach — a lab that requires special-casing the application proves less than
it costs.
It also means the safety story is structural: while `SIP_TRUNK_HOST` points at
Asterisk on the Dev LAN, there is **no route to the PSTN at all**. Not a policy
that could be misconfigured — an absence of route.
---
## Placement in Virgo
| Decision | Value | Why |
|---|---|---|
| Host | **nereid** (`10.0.1.214`) | Terraform describes it as "Experimental Apps (POC, testing new technologies)" — [terraform/incus/containers.tf](../../virgo/terraform/incus/containers.tf). An unauthenticated SIP endpoint is exactly experimental. |
| Deploy | Docker Compose via Ansible | Matches every other Virgo service. `nereid` already has `docker = true`. |
| Hostname | `asterisk.helu.ca` (internal) | LAN only. **No `*.d.helu.ca` HAProxy entry** — HAProxy is HTTP; SIP/RTP would not traverse it, and this must not be publicly reachable. |
| Database | none | Asterisk needs no DB, so the "no databases in Docker" rule is not engaged. |
Hold Slayer itself stays on **triton** (`10.0.1.213`), where it is already
deployed at port 21081. Splitting the two hosts is deliberate: SIP then crosses
a real network with real latency, jitter and MTU, rather than a loopback that
hides every transport problem.
### Ports
Project **210** is Hold Slayer's (existing: `hold_slayer_web_port: 21081`).
Verified free: only `21081` and `29181` are allocated in that space today.
| Var | Port | Purpose |
|---|---|---|
| `asterisk_sip_port` | **21061** | SIP signalling (UDP). `2-10-6-1`: project 210, service 6 (SIP), instance 1 |
| `asterisk_rtp_start` | **21100** | RTP media range start (UDP) |
| `asterisk_rtp_end` | **21149** | RTP range end — 50 ports ≈ 25 concurrent calls, well above `max_concurrent_calls: 4` |
| `asterisk_ari_port` | **21071** | ARI/HTTP management (service 7 = management) |
| `asterisk_syslog_port` | **51462** | Docker syslog, `514YZ` convention (daedalus uses 51461) |
Service digit `6` for SIP is a **new allocation** — the existing scheme
([docs/virgo.md](../../virgo/docs/virgo.md) §Port Numbering) defines 1/2/5/7/8/9
and has no telephony digit. Worth confirming before it becomes precedent.
> **Note:** 5060 is *not* used. The convention forbids random ports, and using
> the well-known SIP port invites scanner traffic. Nothing requires 5060 — both
> ends are configured.
---
## Call scenarios
Each maps to a Hold Slayer behaviour that is currently unproven. Extensions are
what Hold Slayer dials as `number`.
| Ext | Scenario | Proves |
|---|---|---|
| `1001` | **Immediate answer**, plays speech, hangs up after 30s | Baseline: INVITE→200→ACK→RTP→BYE, audio flows both ways, classifier reports `LIVE_HUMAN` |
| `1002` | **IVR menu** — "press 1 for accounts, 2 for cards", branches on DTMF | `send_dtmf` genuinely emits RFC 2833 and Asterisk receives it. This is the big one — currently a no-op in the mock |
| `1003` | **Hold music, then human** — 60s MoH, then answers | The whole hold-slayer loop: classify music → stay on hold → detect human → ring the owner |
| `1004` | **Long hold** — 10 min MoH | `MAX_HOLD_TIME` and the hold-check interval |
| `1005` | **Immediate busy** (`BUSY()`) | Failure path: call marked `FAILED`, no stuck leg |
| `1006` | **Ring, never answer** | Timeout path |
| `1007` | **Answer then hang up after 5s** | Remote-BYE handling, DB persistence on hangup |
| `1008` | **Silence after answer** | Classifier `SILENCE` vs the 30s no-audio case |
`1002` and `1003` are the two that matter most; the rest are cheap to add once
the dialplan exists.
### Classifier determinism
The reason for a scripted IVR rather than a real call: 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. Recommend a **fixed MoH
file committed to the repo** rather than Asterisk's stock music, so the
classifier's input is byte-identical on every run and across hosts.
This is what makes the Phase 0 music-vs-speech precedence fix
([services/audio_classifier.py](../services/audio_classifier.py)) testable
against real audio for the first time.
---
## Security — the part that needs a decision
Two unauthenticated SIP endpoints would exist on the Dev LAN.
**1. Asterisk.** Default `pjsip.conf` examples accept anonymous calls. This lab
must not: `allow_anonymous_inbound = no`, an explicit endpoint for Hold Slayer
with a password, and `permit=` limited to triton's address. Asterisk's default
config is a well-known toll-fraud target and must not be shipped as-is.
**2. Hold Slayer's own SIP listener — pre-existing, flagged earlier.**
`_handle_incoming_register()` replies `200 OK` to any REGISTER with **no digest
challenge**. On loopback that was tolerable. The moment the gateway binds a LAN
interface to talk to Asterisk, any host on the Dev LAN can register as a device
and receive transferred calls.
That is not caused by this lab, but this lab is what makes it reachable. Options,
in order of preference:
1. **Implement digest auth** on inbound REGISTER — the real fix
2. **Bind the SIP listener to a specific interface** and firewall 5060 to triton
↔ nereid only — mitigation, not a fix
3. Accept it explicitly, in writing, as Dev-only
I would not deploy this without at least (2), and (1) is required before
anything resembling production. **This needs your decision before build.**
Also note: `pjsua2` runs `--enable-shared` with TLS available, so SIP-TLS +
SRTP is possible later. Not proposed for the lab — plain UDP keeps `sngrep`
readable, which matters enormously when debugging signalling.
---
## Observability
Match the estate rather than inventing:
- **Logs** — default `json-file` driver, discovered by the host Alloy Docker
socket source and labelled `job=asterisk`. **No syslog listener and no Loki
URL env** — that would double-ship, the same note already carried in
[hold-slayer's compose template](../../virgo/ansible/hold-slayer/docker-compose.yml.j2).
- **Health** — Asterisk has no HTTP health endpoint by default. Enable ARI on
`21071` and probe `/ari/asterisk/info`, which is a genuine liveness signal
(the SIP stack answers), unlike a bare TCP check.
- **`sngrep`** on nereid for live SIP ladder inspection. Not currently installed
anywhere; it is the single most useful tool when signalling misbehaves.
---
## What this does *not* prove
Stated plainly so the lab is not over-trusted:
- **Not real PSTN audio.** No G.711 transcoding artefacts, no packet loss, no
jitter, no carrier-side DTMF mangling. Asterisk is clean; the PSTN is not.
- **Not real IVR behaviour.** Our dialplan is what we imagine a bank sounds
like. Real trees are longer, noisier, and interrupt.
- **Not trunk registration/auth** — that is Kamailio's job (Phase 4a), or the
real trunk's.
- **Not carrier-specific quirks** — each ITSP has its own.
It proves the gateway's *own* logic end to end. That is the majority of the
risk, and it is the part that is currently entirely untested against real media.
---
## Blocking prerequisite — the container runs stub media
**The Hold Slayer Docker image deliberately does not build PJSUA2**
([docs/pjsua2-build.md](pjsua2-build.md)), so the deployed container on triton
runs the media pipeline in **stub mode**. Stub mode's audio calls *return
successfully while doing nothing*.
If the lab runs against the current image, every media test passes while proving
nothing. This is the single most dangerous failure mode in the plan, because it
looks like success.
Two options:
| Option | Effort | Trade-off |
|---|---|---|
| **A. Add a pjproject build stage to the Dockerfile** | Higher — multi-stage build, ~10 min build, larger image | The deployed artefact gains real media. Needed eventually regardless |
| **B. Run Hold Slayer from a venv on triton for the lab** | Lower — the Phase 1b build already exists on caliban | Tests the binaries actually built, but diverges from the deployed artefact |
**Recommendation: A.** B tests something that is not what ships, and the
Dockerfile needs this anyway before Hold Slayer can place a real call from a
container. Doing it now means the lab validates the real artefact. B is a
reasonable short-cut only if you want a fast first signal.
The existing deploy also has a **stale-config finding** — see below — that
touches the same file, so both are worth doing in one pass.
---
## Finding: the deployed compose template is stale
Independent of this lab, [the deployed template](../../virgo/ansible/hold-slayer/docker-compose.yml.j2)
sets `API_TOKEN`, which **no longer exists** — auth is now Casdoor SSO + PATs
via one resolver. The comment "the app's single static bearer across REST/WS/MCP
… required on 0.0.0.0" describes an auth model that was removed.
Live state confirms the service is up and `degraded`/`engine: mock` (correct and
honest). Given `_check_startup_config` refuses SSO-off on a non-loopback bind, it
is worth establishing how it is currently booting — most likely `CASDOOR_ENABLED`
defaults such that the unknown `API_TOKEN` is simply ignored.
Flagging, not fixing — it is outside this design, but it lives in the file the
lab will modify, and `hold_slayer_api_token` is still being pulled from the OCI
vault for a variable the app no longer reads.
---
## Build order
Each step is independently verifiable; none commits you to the next.
1. **Decide** the two open questions: media (A or B), and SIP-listener security
(digest / firewall / accept)
2. **Dialplan + compose**, developed on caliban against a local Asterisk
container — no Virgo changes yet, fastest iteration
3. **Prove `1001`** locally: Hold Slayer places a call, audio flows, classifier
sees `LIVE_HUMAN`. This is the real Phase 2 gate
4. **Prove `1002`/`1003`** locally: DTMF lands, hold→human transition fires
5. **Promote to Virgo** — Ansible role on nereid, `SIP_TRUNK_HOST=nereid.helu.ca`
on triton, re-run 18 across the LAN
6. **Only then** consider Kamailio (4a) or the PSTN (4b)
Steps 24 need no Virgo changes at all, which is worth exploiting: the dialplan
is where the fiddly work is, and iterating locally is far faster than through
Ansible.
---
## Files this would add
```
hold-slayer/
tests/lab/
dialplan/extensions.conf # the 8 scenarios
dialplan/pjsip.conf # endpoint for Hold Slayer, anonymous denied
sounds/hold-music.wav # fixed MoH — deterministic classifier input
docker-compose.lab.yml # local Asterisk for steps 24
README.md # how to run the lab locally
virgo/
ansible/asterisk/
deploy.yml # mirrors ansible/hold-slayer/deploy.yml
docker-compose.yml.j2
extensions.conf.j2
pjsip.conf.j2
ansible/inventory/host_vars/nereid.helu.ca.yml # + asterisk_* vars, + service
```
Dialplan lives in **hold-slayer**, not virgo: it is test fixture data that
belongs with the code it tests, and step 24 iteration needs it locally.
Ansible templates it out to nereid for step 5.
---
## Open questions
1. **Media: A or B?** Determines whether step 5 tests the real artefact.
2. **SIP listener security** — digest auth, firewall, or documented acceptance?
Blocking for step 5, not for steps 24.
3. **Is service digit `6` acceptable for SIP** in the 22XYZ scheme, or should
telephony get a different digit? Sets estate precedent.
4. **`asterisk.helu.ca` DNS** — needs an entry, or is `nereid.helu.ca` on the
allocated port sufficient? (Simpler, and one less thing to maintain.)