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>
294 lines
14 KiB
Markdown
294 lines
14 KiB
Markdown
# 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 1–8 across the LAN
|
||
6. **Only then** consider Kamailio (4a) or the PSTN (4b)
|
||
|
||
Steps 2–4 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 2–4
|
||
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 2–4 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 2–4.
|
||
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.)
|