From c00cf0267649ad27a1c0b4fa2e2dea19d54591f8 Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Wed, 29 Jul 2026 06:06:14 -0400 Subject: [PATCH] =?UTF-8?q?test(lab):=20add=20Asterisk=20lab=20=E2=80=94?= =?UTF-8?q?=20a=20fake=20PSTN=20for=20media=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/asterisk-lab-design.md | 293 +++++++++++++++++++++++++++++ tests/lab/README.md | 147 +++++++++++++++ tests/lab/dialplan/.gitignore | 3 + tests/lab/dialplan/asterisk.conf | 24 +++ tests/lab/dialplan/extensions.conf | 144 ++++++++++++++ tests/lab/dialplan/logger.conf | 8 + tests/lab/dialplan/pjsip.conf | 75 ++++++++ tests/lab/dialplan/rtp.conf | 9 + tests/lab/docker-compose.lab.yml | 28 +++ tests/lab/sounds/.gitignore | 4 + tests/lab/sounds/generate.py | 113 +++++++++++ 11 files changed, 848 insertions(+) create mode 100644 docs/asterisk-lab-design.md create mode 100644 tests/lab/README.md create mode 100644 tests/lab/dialplan/.gitignore create mode 100644 tests/lab/dialplan/asterisk.conf create mode 100644 tests/lab/dialplan/extensions.conf create mode 100644 tests/lab/dialplan/logger.conf create mode 100644 tests/lab/dialplan/pjsip.conf create mode 100644 tests/lab/dialplan/rtp.conf create mode 100644 tests/lab/docker-compose.lab.yml create mode 100644 tests/lab/sounds/.gitignore create mode 100644 tests/lab/sounds/generate.py diff --git a/docs/asterisk-lab-design.md b/docs/asterisk-lab-design.md new file mode 100644 index 0000000..8e25698 --- /dev/null +++ b/docs/asterisk-lab-design.md @@ -0,0 +1,293 @@ +# 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.) diff --git a/tests/lab/README.md b/tests/lab/README.md new file mode 100644 index 0000000..5fe0a15 --- /dev/null +++ b/tests/lab/README.md @@ -0,0 +1,147 @@ +# 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 +> 697–941 Hz, columns 1209–1633 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. diff --git a/tests/lab/dialplan/.gitignore b/tests/lab/dialplan/.gitignore new file mode 100644 index 0000000..402e908 --- /dev/null +++ b/tests/lab/dialplan/.gitignore @@ -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 diff --git a/tests/lab/dialplan/asterisk.conf b/tests/lab/dialplan/asterisk.conf new file mode 100644 index 0000000..2e71279 --- /dev/null +++ b/tests/lab/dialplan/asterisk.conf @@ -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 diff --git a/tests/lab/dialplan/extensions.conf b/tests/lab/dialplan/extensions.conf new file mode 100644 index 0000000..1092a68 --- /dev/null +++ b/tests/lab/dialplan/extensions.conf @@ -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() diff --git a/tests/lab/dialplan/logger.conf b/tests/lab/dialplan/logger.conf new file mode 100644 index 0000000..6c15a54 --- /dev/null +++ b/tests/lab/dialplan/logger.conf @@ -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=. 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 diff --git a/tests/lab/dialplan/pjsip.conf b/tests/lab/dialplan/pjsip.conf new file mode 100644 index 0000000..168bd0f --- /dev/null +++ b/tests/lab/dialplan/pjsip.conf @@ -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 diff --git a/tests/lab/dialplan/rtp.conf b/tests/lab/dialplan/rtp.conf new file mode 100644 index 0000000..248be2c --- /dev/null +++ b/tests/lab/dialplan/rtp.conf @@ -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 }} diff --git a/tests/lab/docker-compose.lab.yml b/tests/lab/docker-compose.lab.yml new file mode 100644 index 0000000..dd9c750 --- /dev/null +++ b/tests/lab/docker-compose.lab.yml @@ -0,0 +1,28 @@ +# Local Asterisk lab — for iterating on caliban before promoting to Virgo. +# +# This is the LOCAL variant: ports and credentials are concrete, not Jinja. +# Ansible templates the same dialplan out to galatea with the estate's +# variables (see virgo/ansible/asterisk/). +# +# Run: docker compose -f docker-compose.lab.yml up -d +# CLI: docker compose -f docker-compose.lab.yml exec asterisk asterisk -rvvv +# +# host networking: SIP/RTP carry IP addresses *inside* the payload, so a +# bridged network needs external_media_address set correctly or the call +# connects with no audio. Host networking sidesteps that entirely for local +# work. The Virgo deploy uses the same approach for the same reason. +services: + asterisk: + image: andrius/asterisk:22.10.1_debian-trixie + container_name: asterisk-lab + network_mode: host + volumes: + - ./dialplan/extensions.conf:/etc/asterisk/extensions.conf:ro + - ./dialplan/pjsip.local.conf:/etc/asterisk/pjsip.conf:ro + - ./dialplan/rtp.local.conf:/etc/asterisk/rtp.conf:ro + - ./dialplan/logger.conf:/etc/asterisk/logger.conf:ro + # The image ships no sound files at all. These are generated by + # sounds/generate.py; Asterisk resolves Playback(lab-music) to + # lab-music.sln here (8kHz signed-linear, no transcoding). + - ./sounds:/var/lib/asterisk/sounds/en:ro + restart: unless-stopped diff --git a/tests/lab/sounds/.gitignore b/tests/lab/sounds/.gitignore new file mode 100644 index 0000000..b524122 --- /dev/null +++ b/tests/lab/sounds/.gitignore @@ -0,0 +1,4 @@ +# Generated by generate.py — deterministic from fixed seeds, so the bytes are +# reproducible and there is no reason to carry ~680K of binary in the repo. +# Run `python generate.py` before starting the lab. +*.sln diff --git a/tests/lab/sounds/generate.py b/tests/lab/sounds/generate.py new file mode 100644 index 0000000..30035c9 --- /dev/null +++ b/tests/lab/sounds/generate.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Generate the lab's audio fixtures. + +The Asterisk container ships with 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. These are synthesised +from a fixed seed, so every run classifies identical input. + +Output is 8 kHz 16-bit mono signed-linear (.sln), which Asterisk plays without +transcoding — the format is implied by the extension, so `Playback(lab-music)` +finds `lab-music.sln`. + + python generate.py [outdir] +""" +import struct +import sys +from pathlib import Path + +import numpy as np + +RATE = 8000 # Asterisk's native rate for ulaw/alaw telephony + + +def _write_sln(path: Path, samples: np.ndarray) -> None: + """Write float samples in [-1, 1] as 16-bit signed little-endian PCM.""" + clipped = np.clip(samples, -1.0, 1.0) + pcm = (clipped * 32767).astype(" np.ndarray: + """Sustained multi-harmonic tones — what the classifier must call MUSIC. + + A chord progression with stable pitch and strong harmonic structure. The + steady spectrum across a long window is what distinguishes music from + speech; this deliberately has no pauses. + """ + t = np.linspace(0, seconds, int(RATE * seconds), endpoint=False) + # A-minor-ish progression, one chord per 2s bar. + chords = [(220.0, 261.6, 329.6), (196.0, 246.9, 293.7), + (174.6, 220.0, 261.6), (196.0, 246.9, 329.6)] + out = np.zeros_like(t) + bar = 2.0 + for i, chord in enumerate(chords * int(np.ceil(seconds / (bar * len(chords))))): + start, end = i * bar, (i + 1) * bar + if start >= seconds: + break + mask = (t >= start) & (t < end) + for j, freq in enumerate(chord): + # Fundamental plus two harmonics, decaying — a plucked-string feel. + for h, amp in ((1, 0.30), (2, 0.12), (3, 0.05)): + out[mask] += amp / (j + 1) * np.sin(2 * np.pi * freq * h * t[mask]) + # Gentle per-bar envelope so bars are distinguishable but never silent. + env = 0.8 + 0.2 * np.sin(2 * np.pi * (t[mask] - start) / bar) + out[mask] *= env + return out * 0.45 + + +def make_speech(seconds: float = 8.0, seed: int = 1337) -> np.ndarray: + """Formant-like bursts with pauses — what the classifier must call SPEECH. + + Not real speech, but it carries the features the classifier keys on: a + fundamental in the human range, shifting formants, and syllable-rate + amplitude modulation with genuine silence between utterances. + """ + rng = np.random.default_rng(seed) + t = np.linspace(0, seconds, int(RATE * seconds), endpoint=False) + out = np.zeros_like(t) + + pos = 0.3 # leading pause + while pos < seconds - 0.4: + syl = rng.uniform(0.12, 0.28) # syllable length + mask = (t >= pos) & (t < pos + syl) + if mask.any(): + local = t[mask] - pos + f0 = rng.uniform(95, 165) # fundamental — adult speaking range + # Two formants, swept slightly across the syllable. The ranges + # deliberately avoid the DTMF bands (rows 697-941, columns + # 1209-1633): a formant pair landing on both trips the Goertzel + # detector and the whole utterance is classified as a keypress. + f1 = rng.uniform(300, 620) + rng.uniform(-40, 40) * local / syl + f2 = rng.uniform(1750, 2600) + rng.uniform(-120, 120) * local / syl + sig = (0.50 * np.sin(2 * np.pi * f0 * local) + + 0.30 * np.sin(2 * np.pi * f1 * local) + + 0.18 * np.sin(2 * np.pi * f2 * local)) + # Raised-cosine envelope: no clicks at syllable edges. + sig *= np.sin(np.pi * local / syl) ** 0.6 + out[mask] += sig + # Inter-syllable gap; occasionally a longer between-word pause. + pos += syl + (rng.uniform(0.25, 0.5) if rng.random() < 0.25 + else rng.uniform(0.04, 0.12)) + return out * 0.55 + + +def make_silence(seconds: float = 5.0) -> np.ndarray: + """Near-silence with a trace of noise — real lines are never digitally flat.""" + rng = np.random.default_rng(4242) + return rng.normal(0, 0.0006, int(RATE * seconds)) + + +def main() -> None: + outdir = Path(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).parent) + outdir.mkdir(parents=True, exist_ok=True) + print(f"Generating lab audio into {outdir}/") + _write_sln(outdir / "lab-music.sln", make_music()) + _write_sln(outdir / "lab-speech.sln", make_speech()) + _write_sln(outdir / "lab-silence.sln", make_silence()) + print("Done. Deterministic: same bytes on every run.") + + +if __name__ == "__main__": + main()