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

14 KiB
Raw Permalink Blame History

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) 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) 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. 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 §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) 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.
  • 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), 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 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.)