Compare commits
17 Commits
e7c84885d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c178bb7bd | |||
| 59f0136370 | |||
| e2051f7486 | |||
| 1644999bcb | |||
| 98c80e3a56 | |||
| 3150f78552 | |||
| c516f659cc | |||
| 92c45e9c4d | |||
| 2a05be27bf | |||
| 7979e70705 | |||
| c00cf02676 | |||
| 204203e3b0 | |||
| e9219f2d4a | |||
| 394e3fc920 | |||
| 4a3c14d4af | |||
| 016d8be71d | |||
| bd078c058e |
69
.claude/rules/auth-surfaces.md
Normal file
69
.claude/rules/auth-surfaces.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
description: Casdoor SSO for the browser + owner-minted PATs for MCP/CLI; owner-only on every surface; one resolver; ?token= fallback; dev-owner on loopback
|
||||
paths:
|
||||
- "auth.py"
|
||||
- "api/auth.py"
|
||||
- "api/tokens.py"
|
||||
- "api/deps.py"
|
||||
- "api/websocket.py"
|
||||
- "mcp_server/server.py"
|
||||
- "main.py"
|
||||
---
|
||||
|
||||
# Authentication across the four surfaces
|
||||
|
||||
Auth is **Casdoor SSO for the browser + owner-minted Personal Access Tokens for
|
||||
MCP/CLI**, and the gateway is **owner-only**: exactly one operator (the Casdoor
|
||||
user whose name matches `OWNER_NAME`) may use any surface; every other identity
|
||||
gets 403. There is one resolver behind all of it — don't add a second auth
|
||||
mechanism, a per-surface token, or a bypass.
|
||||
|
||||
- **`resolve_bearer(session, raw_token)` in [auth.py](../../auth.py) is the single
|
||||
resolver.** It turns a bearer string into a `User` (or `None`), classifying it
|
||||
as a PAT (`hs_pat_` prefix → hash lookup) or a Casdoor JWT (RS256, validated
|
||||
against the endpoint's JWKS). `resolve_from_header_or_query` wraps it to accept
|
||||
the token from the `Authorization` header *or* a `?token=` query param. Every
|
||||
surface funnels through these — REST, WebSocket, MCP.
|
||||
|
||||
- **Owner gating is `is_owner(user)` + `get_current_owner`.** `is_owner` matches
|
||||
`user.name == OWNER_NAME` (SSO) or the dev-owner sub (dev mode). REST routers
|
||||
carry `dependencies=[Depends(get_current_owner)]` (aliased `_auth` in
|
||||
[main.py](../../main.py)) → 401 if unauthenticated, 403 if not owner. New
|
||||
protected routers get the same dependency. `/auth/me` is the one exception: it
|
||||
resolves the user *without* the owner gate so a signed-in non-owner sees
|
||||
`is_owner:false` (the dashboard's "not authorized" screen) instead of a bare
|
||||
401.
|
||||
|
||||
- **The `?token=` query-param fallback is intentional and narrow.** Browsers
|
||||
can't set headers on a WebSocket connect or on an `<audio src>`/`<a href>`
|
||||
recording download, so the current token (Casdoor JWT, or a PAT) rides as
|
||||
`?token=`. It's validated by the same resolver as the header. Don't widen it or
|
||||
remove it without accounting for those two consumers.
|
||||
|
||||
- **WebSocket checks ownership itself** ([api/websocket.py](../../api/websocket.py)
|
||||
`_authorize`) rather than via a router dependency, because WS handshakes don't
|
||||
run FastAPI dependencies the same way. It opens a `session_scope`, resolves the
|
||||
bearer (header or `?token=`), and closes with **4401** unless the caller is the
|
||||
owner. Keep the check **before** `websocket.accept()`.
|
||||
|
||||
- **MCP is gated by the ASGI `_owner_only_mcp` wrapper in main.py**, not by
|
||||
FastMCP auth. `create_mcp_server` builds `FastMCP(auth=None)`; the wrapper reads
|
||||
the ASGI scope's `Authorization` header, resolves it via the same
|
||||
`resolve_from_header_or_query` + `is_owner`, and short-circuits non-owner
|
||||
requests with 401/403 (plus an RFC 9728 `WWW-Authenticate` header pointing at
|
||||
`/.well-known/oauth-protected-resource/mcp`). This is why PATs *and* JWTs both
|
||||
work on `/mcp` with one code path. The nested `mcp_http_app.lifespan` still runs
|
||||
— the wrapper is pure middleware around the inner app.
|
||||
|
||||
- **Dev mode is the loopback bypass, not a token.** `CASDOOR_ENABLED=false` makes
|
||||
every request resolve to the dev owner — permitted **only** on a loopback bind.
|
||||
`_check_startup_config` in [main.py](../../main.py) exits if SSO is disabled and
|
||||
`HOST` is off-loopback (the network would see a dev-owner-open gateway), and
|
||||
exits if SSO is enabled but `CASDOOR_ENDPOINT`/`CLIENT_ID`/`CLIENT_SECRET`/
|
||||
`OWNER_NAME` is missing. **Never weaken these to "warn and continue" — a
|
||||
startup misconfiguration must stop the service.**
|
||||
|
||||
- **Never log a token.** `client_secret` is a `SecretStr`; read it via
|
||||
`.get_secret_value()` only where you hand it to the Casdoor SDK. PAT plaintext
|
||||
is shown once at creation and only its SHA-256 hash is stored — never log the
|
||||
plaintext, a JWT, or a hash.
|
||||
45
.claude/rules/call-safety.md
Normal file
45
.claude/rules/call-safety.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: emergency-number guard, concurrent-call cap, dial-path discipline — the outbound safety invariants
|
||||
paths:
|
||||
- "core/dial_plan.py"
|
||||
- "core/gateway.py"
|
||||
- "mcp_server/server.py"
|
||||
- "api/calls.py"
|
||||
---
|
||||
|
||||
# Outbound-call safety
|
||||
|
||||
This is the safety core. A defect here means an unwanted real phone call, a
|
||||
runaway telephony bill, or — the one that matters most — an AI-initiated
|
||||
emergency call that should have been impossible.
|
||||
|
||||
- **`is_emergency_number()` is the single source of truth for refusal.** It
|
||||
lives in `core/dial_plan.py`, blocks `911`/`9911`/`112` and their E.164
|
||||
mappings (`_BLOCKED` = keys ∪ values), and normalises the input (strips
|
||||
spaces, dashes, dots) before comparing. If you learn of another dialled form
|
||||
that reaches emergency services, add it to `EMERGENCY_NUMBERS` — never work
|
||||
around the guard.
|
||||
|
||||
- **Every outbound path goes through `gateway.make_call`, and the guard is its
|
||||
first check** — before the concurrency cap, before `create_call`, before any
|
||||
SIP action. REST `make_call`, MCP `make_call`, receptionist ring-back, and any
|
||||
transfer to an external number must funnel through it. **Do not introduce a
|
||||
dial path that reaches `sip_engine.make_call` without passing the guard
|
||||
first.** If a new feature needs to place a call, it calls `gateway.make_call`.
|
||||
|
||||
- **The concurrency cap is spend control, not decoration.** `max_concurrent_calls`
|
||||
(default 4) is checked in `make_call` after the emergency guard and before
|
||||
call creation, using `len(call_manager.active_calls)`. Keep the ordering:
|
||||
refuse-emergency, then cap, then create. Don't move the count to after
|
||||
creation (it would off-by-one) and don't remove it.
|
||||
|
||||
- **Refusals raise `ValueError` at the gateway; surfaces translate it.**
|
||||
`make_call` raises `ValueError` for both refusals; the MCP tool converts it to
|
||||
`ToolError`, and the REST layer maps it to a 4xx. Keep refusals as exceptions
|
||||
from the gateway — a refused call must never look like a placed one.
|
||||
|
||||
- **The README's `[!CAUTION]` block is a contract, not decoration.** If you
|
||||
change refusal behaviour, the README caution and this rule must stay true. A
|
||||
system that quietly stops refusing emergency numbers is a serious regression
|
||||
even if every test still passes — add/keep a test that asserts each blocked
|
||||
form is refused.
|
||||
55
.claude/rules/concurrency-threads.md
Normal file
55
.claude/rules/concurrency-threads.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
description: the Sippy/PJSUA2 OS-thread boundary — two funnels, who owns what state, never cross it directly
|
||||
paths:
|
||||
- "core/sippy_engine.py"
|
||||
- "core/sip_engine.py"
|
||||
- "core/media_pipeline.py"
|
||||
- "core/call_manager.py"
|
||||
- "core/gateway.py"
|
||||
---
|
||||
|
||||
# The thread boundary (the invariant that keeps this app sane)
|
||||
|
||||
The README says "single-process async." That's true at the surface, but under
|
||||
the SIP engine there are **two execution contexts**: the asyncio event loop, and
|
||||
a dedicated **Sippy/PJSUA2 OS thread** running the `ED2` event dispatcher. Almost
|
||||
every hard-to-debug class of bug in a telephony gateway comes from touching one
|
||||
context's state from the other. This app avoids that with exactly one funnel each
|
||||
way. Preserve them.
|
||||
|
||||
- **Who owns what:**
|
||||
- *Sippy thread* owns the Sippy UA objects and the `ED2` dispatcher. State:
|
||||
`_ed_ua_to_leg`, `_ed_leg_to_ua` (the "ED-thread-owned state" maps). Only
|
||||
touch these from a Sippy handler or a `_run_on_sippy` closure.
|
||||
- *asyncio loop* owns everything else: `_legs`, `_bridges`,
|
||||
`_registered_devices`, the `EventBus`, the `CallManager`, the media pipeline
|
||||
wiring.
|
||||
|
||||
- **Cross thread → loop only via `_post_from_ed`.** It calls
|
||||
`asyncio.run_coroutine_threadsafe(self._on_engine_event(kind, data), self._loop)`.
|
||||
`_on_engine_event` is **the single funnel** where Sippy-thread events mutate
|
||||
loop-owned state, and it runs on the loop. New Sippy-side events post through
|
||||
here with a new `kind`; they do **not** reach into `_legs`/`EventBus` directly
|
||||
from the handler.
|
||||
|
||||
- **Cross loop → thread only via `_run_on_sippy`.** It uses `ED2.callFromThread(fn)`
|
||||
so `fn` runs where the Sippy objects live. In simulation mode (no `sippy`
|
||||
import) it runs `fn` inline — keep that fallback so tests and stub mode work
|
||||
without the native library. Anything that manipulates a UA object goes through
|
||||
here.
|
||||
|
||||
- **Never:** read/write a Sippy UA object from the loop; never mutate `_legs`,
|
||||
publish an event, or touch the `CallManager` from inside a raw Sippy callback
|
||||
without going through `_post_from_ed`. If you find yourself wanting to, you're
|
||||
about to introduce a data race — add a `kind` to the funnel instead.
|
||||
|
||||
- **Background tasks are tracked, both sides.** The gateway's `spawn()` and the
|
||||
engine's `_spawn()` add tasks to a `_tasks` set with a done-callback that
|
||||
discards them, so shutdown can cancel them and the GC can't drop a live
|
||||
coroutine. Launch per-call/background work through these, not a bare
|
||||
`asyncio.create_task` you forget to hold a reference to.
|
||||
|
||||
- **`MockSIPEngine` has no thread.** Tests run against it; it satisfies the same
|
||||
`SIPEngine` interface synchronously/async-inline. When you extend the real
|
||||
engine's behaviour, extend the mock to match, or tests will pass against a
|
||||
fiction.
|
||||
63
.claude/rules/config-startup.md
Normal file
63
.claude/rules/config-startup.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
description: pydantic-settings nested sub-configs + get_settings() singleton, SecretStr discipline, startup refusals, .env hygiene
|
||||
paths:
|
||||
- "config.py"
|
||||
- "main.py"
|
||||
- ".env*"
|
||||
---
|
||||
|
||||
# Config & startup
|
||||
|
||||
Config is `Settings` in [config.py](../../config.py): a root `BaseSettings` with
|
||||
**nested sub-config models**, each carrying its own `env_prefix`. Read it through
|
||||
the `get_settings()` cached singleton.
|
||||
|
||||
- **`get_settings()` is the only accessor.** It memoises a single `Settings()`.
|
||||
Don't construct `Settings()` elsewhere, and don't reach for `os.environ.get`
|
||||
for Hold Slayer config — the whole point of the sub-config layout is that every
|
||||
knob has one typed home.
|
||||
|
||||
- **Sub-configs own their prefixes.** `SIP_TRUNK_*` → `SIPTrunkSettings`, `LLM_*`
|
||||
→ `LLMSettings`, `TTS_*` → `TTSSettings`, `RECEPTIONIST_*` →
|
||||
`ReceptionistSettings`, `CASDOOR_*` → `CasdoorSettings`, `CLASSIFIER_*`,
|
||||
`SPEACHES_*`, `GATEWAY_SIP_*`. Root vars (`DATABASE_URL`, `HOST`, `PORT`,
|
||||
`MAX_CONCURRENT_CALLS`, `USE_MOCK_SIP`, `NOTIFY_SMS_NUMBER`, `DEBUG`,
|
||||
`LOG_LEVEL`, and the auth cross-cutters `OWNER_NAME` + `PUBLIC_BASE_URL`) are
|
||||
unprefixed on the root model. A new knob goes in the sub-config it belongs to;
|
||||
a genuinely new subsystem gets its own sub-config + prefix, not flat root vars.
|
||||
- `OWNER_NAME` (the owner's Casdoor username) and `PUBLIC_BASE_URL` (OAuth
|
||||
discovery base) live on the root, not under `CASDOOR_`, because they cross-cut
|
||||
every surface — like `DATABASE_URL`. The Casdoor *connection* knobs
|
||||
(`enabled`/`endpoint`/`client_id`/`client_secret`/`org_name`/`app_name`) live
|
||||
under `CASDOOR_`.
|
||||
- `HoldSlayerSettings` uses `env_prefix_allow_empty=True` with explicit
|
||||
`validation_alias`es (`DEFAULT_TRANSFER_DEVICE`, `MAX_HOLD_TIME`,
|
||||
`HOLD_CHECK_INTERVAL`) — i.e. those three are read *unprefixed* by design.
|
||||
Follow that pattern only if you deliberately want an unprefixed name.
|
||||
|
||||
- **Secrets are `SecretStr`.** `casdoor.client_secret`, `sip_trunk.password`,
|
||||
`llm.api_key`, `tts.api_key`. Keep new secrets as `SecretStr`; call
|
||||
`.get_secret_value()` only at the point of use (outbound header, SDK
|
||||
construction) — never store the bare string, never log it.
|
||||
|
||||
- **Startup refuses bad configs loudly, then exits.** In [main.py](../../main.py):
|
||||
- `_check_startup_config` exits if `DATABASE_URL` is unset; if `CASDOOR_ENABLED`
|
||||
is true but any of `CASDOOR_ENDPOINT`/`CLIENT_ID`/`CLIENT_SECRET`/`OWNER_NAME`
|
||||
is missing; or if `CASDOOR_ENABLED` is false while `HOST` is off-loopback
|
||||
(dev-owner mode would be open to the network). See the
|
||||
[auth-surfaces rule](auth-surfaces.md).
|
||||
- `_handle_db_error` turns raw asyncpg failures into human-readable guidance
|
||||
(wrong password, missing DB, connection refused, bad hostname) and `sys.exit(1)`.
|
||||
- SIP engine build failure and mock-vs-real are surfaced, not swallowed.
|
||||
**Keep the pattern: a misconfiguration stops the service with a message a human
|
||||
can act on — never a silent degrade or a stack trace with no guidance.**
|
||||
|
||||
- **`use_mock_sip` is opt-in for a reason.** An unconfigured trunk without
|
||||
`USE_MOCK_SIP=true` must fail startup rather than boot a gateway that silently
|
||||
can't place real calls. Don't default it to `True`.
|
||||
|
||||
- **`.env` hygiene:** `.env` is gitignored and holds real secrets — never commit
|
||||
it, never treat the checked-out `.env` as a template. Only `.env.example`
|
||||
(placeholders) is committed, and it must stay in sync with the models here and
|
||||
the README config table. Every new var lands in all three: model,
|
||||
`.env.example`, README.
|
||||
56
.claude/rules/lifespan-wiring.md
Normal file
56
.claude/rules/lifespan-wiring.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: composition-root lifespan, nested MCP http_app lifespan, app.state wiring, route/mount ordering, honest /health
|
||||
paths:
|
||||
- "main.py"
|
||||
- "api/deps.py"
|
||||
- "core/gateway.py"
|
||||
---
|
||||
|
||||
# Lifespan, composition root & route ordering
|
||||
|
||||
[main.py](../../main.py)'s `lifespan` is the **composition root**: it builds the
|
||||
gateway and every service, wires them by constructor/registration, and hangs the
|
||||
long-lived ones on `app.state`. This is the one place dependencies are
|
||||
assembled.
|
||||
|
||||
- **Build services here, inject them — nothing self-constructs its deps.** The
|
||||
gateway, classifier, transcription, TTS, routing, recording, receptionist, and
|
||||
notification services are all constructed in the lifespan and wired together
|
||||
(e.g. the receptionist receives tts/transcription/recording/routing;
|
||||
`launch_hold_slayer` is registered as the `HOLD_SLAYER` mode handler). A new
|
||||
service is built here and passed in, not instantiated deep in a call path.
|
||||
|
||||
- **The MCP sub-app's lifespan MUST be nested.** The lifespan opens
|
||||
`async with mcp_http_app.lifespan(app):` around all startup. FastMCP's
|
||||
streamable-HTTP session manager is initialised inside *its* lifespan; mount the
|
||||
app without entering that context and every `/mcp` request 500s
|
||||
("session manager not initialised" / "Task group is not initialized"). **Keep
|
||||
the nesting.** This is the same landmine across the estate's mounted-MCP
|
||||
services.
|
||||
|
||||
- **`app.state` is the handoff to request handlers.** The lifespan sets
|
||||
`app.state.gateway`, `.routing_service`, `.transcription_service`,
|
||||
`.notification_service`, `.recording_service`. Dependencies in
|
||||
[api/deps.py](../../api/deps.py) read these and raise `503` if not yet set. MCP
|
||||
tools reach the gateway via the lazy `_get_gateway_instance` resolver. Don't
|
||||
reach for module-level globals; go through `app.state`.
|
||||
|
||||
- **Route/mount registration order is load-bearing:**
|
||||
1. `call_history` router registers **before** `calls` — both live under
|
||||
`/api/v1/calls`, and `calls`' `GET /{call_id}` would otherwise swallow the
|
||||
literal path `history`. Keep history first.
|
||||
2. The `"/mcp"` mount and all API/WS/health routes register **before** the
|
||||
`"/"` static dashboard mount — a root mount matches every path, so anything
|
||||
after it is unreachable. The dashboard mount stays last, and only when
|
||||
`dashboard/build/` exists.
|
||||
|
||||
- **`/health` is honest by construction.** `healthy` = real (non-`MockSIPEngine`)
|
||||
engine **and** registered trunk **and** reachable DB; it also reports STT/TTS
|
||||
last-known reachability via `_availability`. Don't relax any of these to make a
|
||||
probe pass — a degraded gateway must read as `degraded`, with the reason
|
||||
visible.
|
||||
|
||||
- **Shutdown reverses startup.** Stop notifications, stop the gateway (which
|
||||
cancels tracked tasks, ends active calls, stops SIP then media), close the DB.
|
||||
New long-lived resources get a matching teardown here — don't leak a task or a
|
||||
client across restarts.
|
||||
56
.claude/rules/mcp-tools.md
Normal file
56
.claude/rules/mcp-tools.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: MCP tools return formatted strings; ToolError-vs-return-string convention; lazy gateway resolution; resources are JSON
|
||||
paths:
|
||||
- "mcp_server/server.py"
|
||||
---
|
||||
|
||||
# MCP tools & resources
|
||||
|
||||
The MCP server ([mcp_server/server.py](../../mcp_server/server.py)) is the
|
||||
AI-assistant control surface. It's built by `create_mcp_server(get_gateway)` and
|
||||
mounted at `/mcp/` before the lifespan runs, so everything is resolved lazily.
|
||||
|
||||
- **Auth is the ASGI `_owner_only_mcp` wrapper in [main.py](../../main.py), not
|
||||
FastMCP.** `create_mcp_server` builds `FastMCP(auth=None)`; the wrapper resolves
|
||||
the `Authorization` bearer (Casdoor JWT or owner-minted PAT) via the shared
|
||||
`resolve_from_header_or_query` + `is_owner` and returns 401/403 before the inner
|
||||
app runs. Don't reintroduce a FastMCP verifier here — one resolver gates all
|
||||
four surfaces (see the [auth-surfaces rule](auth-surfaces.md)). MCP clients use
|
||||
a PAT (`hs_pat_…`) minted from the dashboard's Tokens modal.
|
||||
|
||||
- **Tools return plain formatted strings; resources return JSON strings.** Tools
|
||||
produce human-readable text an assistant reads back to a user (`"Call abc123
|
||||
initiated. …"`). Resources (`gateway://status`, `gateway://call-flows`,
|
||||
`gateway://active-calls`) return `json.dumps(...)`. Don't blur these — a tool
|
||||
that returns raw JSON, or a resource that returns prose, breaks the contract.
|
||||
|
||||
- **Error convention — match the two existing patterns:**
|
||||
- **Raise `ToolError`** when the request is invalid or unsafe: emergency
|
||||
number, bad mode, concurrency cap hit, or "gateway still starting"
|
||||
(`require_gateway`). The assistant should treat these as errors.
|
||||
- **Return an error string** for a lookup that simply found nothing or hit a
|
||||
recoverable snag: `"Call {id} not found."`, `"No stored call flow for …"`,
|
||||
`"Error looking up …: {e}"`. The assistant reads these as content.
|
||||
- Rule of thumb: *"you asked for something invalid/unsafe" → raise; "I looked,
|
||||
here's the (maybe empty/failed) answer" → return.*
|
||||
|
||||
- **`require_gateway()` gates every tool that needs the live gateway.** It raises
|
||||
`ToolError("Gateway is still starting up …")` when `get_gateway()` returns
|
||||
`None`. This is why the MCP app can mount before the lifespan builds the
|
||||
gateway. Call it at the top of any tool that touches the gateway; never assume
|
||||
the gateway exists.
|
||||
|
||||
- **`make_call` is the one tool that dials.** It maps the string `mode` to
|
||||
`CallMode`, defaults unknown modes to `DIRECT`, and lets `gateway.make_call`'s
|
||||
refusals (`ValueError`) surface as `ToolError`. The emergency guard and
|
||||
concurrency cap live in the gateway, **not** here — don't reimplement or skip
|
||||
them at the tool layer (see the call-safety rule).
|
||||
|
||||
- **DB-backed tools use `session_scope()`** and read from `call_persistence`.
|
||||
Completed-call history, summaries, recordings, and stored flows come from the
|
||||
database, not from `active_calls` (those are live only). Keep the
|
||||
`async with session_scope() as session:` pattern; don't open ad-hoc sessions.
|
||||
|
||||
- **Keep the tool count and README table in sync.** There are 15 tools + 3
|
||||
resources. If you add/remove one, update the README's MCP table and the
|
||||
`docs/mcp-server.md` reference — a drifting tool list is a documented lie.
|
||||
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@@ -0,0 +1,34 @@
|
||||
# Secrets — never bake into the image (injected at runtime via compose env).
|
||||
.env
|
||||
|
||||
# The dashboard is rebuilt in the node stage and COPY'd in fresh; keep the
|
||||
# gitignored working-tree copies out of the build context.
|
||||
dashboard/build/
|
||||
dashboard/node_modules/
|
||||
dashboard/.svelte-kit/
|
||||
|
||||
# Python build/cache cruft.
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Local runtime artifacts.
|
||||
recordings/
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# VCS / editor / OS.
|
||||
.git/
|
||||
.gitea/
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Not needed at runtime.
|
||||
tests/
|
||||
docs/
|
||||
34
.env.compose.example
Normal file
34
.env.compose.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# Compose environment for `docker compose up` — copy to `.env` and fill in.
|
||||
#
|
||||
# cp .env.compose.example .env
|
||||
#
|
||||
# These values are substituted into docker-compose.yaml (${VAR}); they are NOT
|
||||
# baked into the image (.env is gitignored and in .dockerignore). Distinct from
|
||||
# the app's own .env used for a bare `uvicorn` run.
|
||||
|
||||
# --- Database (the bundled postgres:17 service) ---
|
||||
HS_DB_USER=holdslayer
|
||||
HS_DB_PASSWORD=change-me
|
||||
HS_DB_NAME=holdslayer
|
||||
|
||||
# --- Published port on the host ---
|
||||
HS_APP_PORT=21081
|
||||
|
||||
# --- SIP: mock by default (dev/local). Set false + fill SIP_TRUNK_* for a real trunk. ---
|
||||
USE_MOCK_SIP=true
|
||||
|
||||
# --- Auth: Casdoor SSO (owner-only) ---
|
||||
# Required: this stack publishes the port on 0.0.0.0, so dev-owner mode
|
||||
# (CASDOOR_ENABLED=false) is refused at startup — it's loopback-only. Register a
|
||||
# `hold-slayer` app in Casdoor (org heluca, redirect URI <PUBLIC_BASE_URL>/auth/callback).
|
||||
CASDOOR_ENABLED=true
|
||||
CASDOOR_ENDPOINT=https://id.ouranos.helu.ca
|
||||
CASDOOR_CLIENT_ID=
|
||||
CASDOOR_CLIENT_SECRET=
|
||||
CASDOOR_ORG_NAME=heluca
|
||||
CASDOOR_APP_NAME=hold-slayer
|
||||
# The owner's Casdoor username — the only identity allowed on any surface.
|
||||
OWNER_NAME=
|
||||
# Public base URL the browser reaches (drives OAuth discovery + the Casdoor
|
||||
# redirect_uri). E.g. http://localhost:21081 for a local run.
|
||||
PUBLIC_BASE_URL=http://localhost:21081
|
||||
50
.env.example
50
.env.example
@@ -1,15 +1,32 @@
|
||||
# ============================================================
|
||||
# Hold Slayer Gateway Configuration
|
||||
# ============================================================
|
||||
# Copy to .env and fill in your values
|
||||
# Copy to .env and fill in your values. This is the app's own .env for a bare
|
||||
# `uvicorn main:app` run — see .env.compose.example for the Docker stack.
|
||||
|
||||
# --- Database (required) ---
|
||||
DATABASE_URL=postgresql+asyncpg://holdslayer:<db-password>@localhost:5432/holdslayer
|
||||
|
||||
# --- API auth (required unless HOST=127.0.0.1) ---
|
||||
# One static bearer token shared by REST, WebSocket (?token=...), and MCP.
|
||||
# Generate with: openssl rand -hex 32
|
||||
API_TOKEN=
|
||||
# --- Auth: Casdoor SSO + owner-minted PATs (owner-only) ---
|
||||
# The browser signs in via Casdoor (short-lived JWT); MCP/CLI clients use
|
||||
# owner-minted PATs (hs_pat_…). Both resolve to a User gated to OWNER_NAME.
|
||||
#
|
||||
# Two supported configurations, enforced at startup:
|
||||
# 1. CASDOOR_ENABLED=true + endpoint/client_id/client_secret/OWNER_NAME set
|
||||
# 2. CASDOOR_ENABLED=false + HOST=127.0.0.1 (dev-owner mode, loopback ONLY)
|
||||
# SSO-off with an off-loopback HOST is refused — it would resolve every request
|
||||
# to the dev owner, open to the network.
|
||||
CASDOOR_ENABLED=true
|
||||
CASDOOR_ENDPOINT=https://id.example.com
|
||||
CASDOOR_CLIENT_ID=
|
||||
CASDOOR_CLIENT_SECRET=
|
||||
CASDOOR_ORG_NAME=
|
||||
CASDOOR_APP_NAME=hold-slayer
|
||||
# The owner's Casdoor username — the only identity allowed on any surface.
|
||||
OWNER_NAME=
|
||||
# Public base URL the browser reaches (drives OAuth discovery + the Casdoor
|
||||
# redirect_uri). Blank derives it from the request headers.
|
||||
PUBLIC_BASE_URL=
|
||||
|
||||
# --- SIP Trunk ---
|
||||
# The mock engine must be requested explicitly; an unconfigured trunk
|
||||
@@ -26,13 +43,23 @@ SIP_TRUNK_DID=+15551234567
|
||||
# --- Gateway SIP Listener ---
|
||||
# Port for devices (softphones/hardphones) to register to
|
||||
GATEWAY_SIP_HOST=0.0.0.0
|
||||
GATEWAY_SIP_PORT=5080
|
||||
GATEWAY_SIP_DOMAIN=gateway.helu.ca
|
||||
GATEWAY_SIP_PORT=5060
|
||||
GATEWAY_SIP_DOMAIN=gateway.local
|
||||
|
||||
# --- Speaches STT ---
|
||||
SPEACHES_URL=http://localhost:22070
|
||||
SPEACHES_MODEL=whisper-large-v3
|
||||
|
||||
# --- Rhema TTS (OpenAI-compatible /v1/audio/speech) ---
|
||||
# Must NOT point at this app's own port (default PORT=8000) — set a real
|
||||
# endpoint or TTS requests loop back into the gateway.
|
||||
TTS_BASE_URL=http://localhost:8001
|
||||
TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
TTS_VOICE=af_heart
|
||||
TTS_API_KEY=
|
||||
TTS_TIMEOUT=30.0
|
||||
TTS_SAMPLE_RATE=16000
|
||||
|
||||
# --- Audio Classifier ---
|
||||
# Thresholds for hold music detection (0.0 - 1.0)
|
||||
CLASSIFIER_MUSIC_THRESHOLD=0.7
|
||||
@@ -50,6 +77,12 @@ LLM_TIMEOUT=30.0
|
||||
LLM_MAX_TOKENS=1024
|
||||
LLM_TEMPERATURE=0.3
|
||||
|
||||
# --- AI Receptionist (inbound calls) ---
|
||||
RECEPTIONIST_ENABLED=true
|
||||
RECEPTIONIST_LISTEN_TIMEOUT_S=15.0
|
||||
RECEPTIONIST_END_OF_UTTERANCE_SILENCE_S=1.2
|
||||
RECEPTIONIST_MESSAGE_MAX_SECONDS=90
|
||||
|
||||
# --- Hold Slayer ---
|
||||
# Default device to transfer to when human detected
|
||||
DEFAULT_TRANSFER_DEVICE=sip_phone
|
||||
@@ -67,6 +100,9 @@ HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=false
|
||||
LOG_LEVEL=info
|
||||
# Log rendering: "text" (human-readable) or "json" (one object per line, for
|
||||
# Loki/Alloy). The Docker image sets json; text is the default for local dev.
|
||||
LOG_FORMAT=text
|
||||
|
||||
# --- Safety ---
|
||||
# Max simultaneous calls the gateway will place (REST + MCP)
|
||||
|
||||
105
.gitea/workflows/cve-scan-docker-build.yml
Normal file
105
.gitea/workflows/cve-scan-docker-build.yml
Normal file
@@ -0,0 +1,105 @@
|
||||
name: CVE Scan & Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A pushed version tag (e.g. 0.2.0 or v0.2.0) cuts a release: the build
|
||||
# below stamps the image with the matching semver tag (e.g. :0.2.0) so the
|
||||
# deploy can pin an immutable release instead of a moving :latest/:sha.
|
||||
tags: ['*']
|
||||
|
||||
env:
|
||||
REGISTRY: git.helu.ca
|
||||
IMAGE_NAME: ${{ gitea.repository }}
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin
|
||||
trivy --version
|
||||
|
||||
- name: Install pip-tools and resolve Python dependencies
|
||||
run: |
|
||||
python3 -m venv /tmp/scanenv
|
||||
/tmp/scanenv/bin/pip install --quiet pip-tools
|
||||
/tmp/scanenv/bin/pip-compile pyproject.toml \
|
||||
-o /tmp/requirements.txt \
|
||||
--no-header --quiet --allow-unsafe --strip-extras \
|
||||
--resolver=backtracking || {
|
||||
/tmp/scanenv/bin/pip install --quiet . && \
|
||||
/tmp/scanenv/bin/pip freeze > /tmp/requirements.txt
|
||||
}
|
||||
cat /tmp/requirements.txt
|
||||
|
||||
- name: Scan Python dependencies for CVEs
|
||||
continue-on-error: true
|
||||
run: |
|
||||
trivy fs --scanners vuln --severity HIGH,CRITICAL --format table /tmp/requirements.txt
|
||||
|
||||
- name: Audit dashboard npm dependencies
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd dashboard
|
||||
npm ci --ignore-scripts
|
||||
npm audit --audit-level=high || true
|
||||
|
||||
- name: Scan repository for secrets
|
||||
continue-on-error: true
|
||||
run: |
|
||||
trivy fs --scanners secret --severity HIGH,CRITICAL --format table .
|
||||
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
needs: security-scan
|
||||
if: always()
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||
|
||||
# Single image: Hold Slayer is one FastAPI process exposing REST/WS/MCP
|
||||
# and serving its own built SvelteKit dashboard at "/" — no separate
|
||||
# web/nginx image. The Dockerfile's node stage builds the dashboard.
|
||||
- name: Extract metadata for image
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/main' }}
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan image for CVEs
|
||||
continue-on-error: true
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin
|
||||
IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -n1)
|
||||
echo "Scanning image: ${IMAGE_TAG}"
|
||||
trivy image --severity HIGH,CRITICAL --format table "${IMAGE_TAG}"
|
||||
249
CLAUDE.md
Normal file
249
CLAUDE.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# CLAUDE.md — Hold Slayer 🔥🐾
|
||||
|
||||
Red Panda Standards for the Hold Slayer telephony gateway. This is an
|
||||
**AI-powered PSTN gateway**: it places real phone calls, navigates IVR menus,
|
||||
waits on hold, and rings a human's desk phone when a live person answers. It
|
||||
also answers inbound calls with an AI receptionist and smart routing.
|
||||
|
||||
**It dials real numbers on a real SIP trunk and may incur telephony charges,
|
||||
and an AI agent drives it.** Treat every change through that lens: a bug here
|
||||
isn't a 500, it's an unwanted phone call — or a *refused emergency call that
|
||||
should never have been attempted in the first place*. Read this file before
|
||||
touching call placement, auth, the SIP thread boundary, or the emergency guard.
|
||||
|
||||
Lead with a paw print in this repo.
|
||||
|
||||
---
|
||||
|
||||
## The shape of this thing (read once, then it's obvious)
|
||||
|
||||
One FastAPI process exposes four surfaces over the same port: **REST** (`/api/v1/*`),
|
||||
**WebSocket** (`/ws/*`), an **MCP server** (streamable HTTP at `/mcp/`), and the
|
||||
built **SvelteKit dashboard** at `/`. All four are **owner-only**, gated by Casdoor
|
||||
SSO (browser JWT) or an owner-minted PAT through one shared resolver.
|
||||
|
||||
Under them sits a **composition root** in [main.py](main.py)'s `lifespan`: the
|
||||
gateway and every service are constructed and wired there, then hung on
|
||||
`app.state`. Nothing constructs its own dependencies — if you need a new
|
||||
service, build it in the lifespan and pass it in.
|
||||
|
||||
Below the services is the part that makes this app unusual: a **`SippyB2BUAEngine`
|
||||
that runs the SIP/PJSUA2 event loop on its own OS thread**, not the asyncio loop.
|
||||
This is the highest-leverage invariant in the codebase. See
|
||||
[the concurrency rule](.claude/rules/concurrency-threads.md) — the README's
|
||||
"single-process async" line is a simplification; there are two execution
|
||||
contexts and exactly one funnel between them.
|
||||
|
||||
```
|
||||
REST / WS / MCP / Dashboard (asyncio, FastAPI)
|
||||
│
|
||||
composition root (lifespan) → services → gateway
|
||||
│
|
||||
SippyB2BUAEngine ──┬── asyncio side (legs, bridges, event bus)
|
||||
└── Sippy thread (UA objects, ED2 dispatcher)
|
||||
↑ crossed only via _post_from_ed / _run_on_sippy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Red Panda Approval™ — what it means here
|
||||
|
||||
1. **Fresh Environment Test** — `cp .env.example .env`, set `DATABASE_URL` and
|
||||
either `CASDOOR_*` + `OWNER_NAME` (SSO) or `CASDOOR_ENABLED=false` with
|
||||
`HOST=127.0.0.1` (dev-owner, loopback only), `pip install -e ".[dev]"`,
|
||||
`uvicorn main:app`. It must boot to a clear log banner or **exit with a
|
||||
human-readable reason** (see `_check_startup_config` / `_handle_db_error` in
|
||||
[main.py](main.py)). It must *never* boot into a state where it silently can't
|
||||
place calls — that's why `use_mock_sip` is opt-in and an unconfigured trunk
|
||||
fails startup.
|
||||
2. **Elegant Simplicity** — the composition root wires; services do one job;
|
||||
the thread boundary has exactly one funnel each way. Don't add a second path
|
||||
across the thread line, a second auth mechanism, or a service that reaches
|
||||
into another service's internals.
|
||||
3. **Observable & Debuggable** — `/health` is *honest*: it reports `degraded`
|
||||
with the reason (mock engine, unregistered trunk, DB down, STT/TTS
|
||||
unreachable) rather than a green light that lies. Keep it honest. Events flow
|
||||
through the typed `EventBus`; new call-lifecycle facts become typed events,
|
||||
not `print`s.
|
||||
4. **Consistent Patterns** — config via pydantic-settings sub-configs; MCP tools
|
||||
return formatted strings; REST returns Pydantic models; DB access via
|
||||
`session_scope()`. Match the neighbours.
|
||||
5. **Actually Works** — `pytest tests/ -v` (189 tests across 19 files). A change
|
||||
to call placement, routing, the classifier, or auth needs a test. The suite
|
||||
runs against SQLite (`aiosqlite`) and the mock SIP engine — no trunk, no
|
||||
Postgres required to test.
|
||||
|
||||
---
|
||||
|
||||
## Invariants that must not be weakened
|
||||
|
||||
These are safety- and correctness-critical. Loosening one is never a casual
|
||||
refactor — it needs an explicit rationale and, where it deviates from a stated
|
||||
standard, a note (there is no `docs/EXCEPTIONS.md` yet; if you start
|
||||
accumulating documented deviations, create one rather than letting them go
|
||||
unrecorded).
|
||||
|
||||
- **The emergency-number guard is absolute.** `is_emergency_number()` in
|
||||
[core/dial_plan.py](core/dial_plan.py) blocks `911`/`9911`/`112` (and their
|
||||
E.164 forms, whitespace/dashes stripped) at `gateway.make_call`, **before**
|
||||
the concurrency check and before any SIP action. Every dialling path — REST
|
||||
`make_call`, MCP `make_call`, receptionist call-back, transfer to an external
|
||||
number — must pass through a guard that refuses these. An AI agent must never
|
||||
place an emergency call, and API calls carry no E911 location. **Do not add a
|
||||
dial path that bypasses `make_call`'s guard.** If you add a new outbound path,
|
||||
it calls the guard first. See [the safety rule](.claude/rules/call-safety.md).
|
||||
|
||||
- **The concurrency cap is real spend control.** `max_concurrent_calls` (default
|
||||
4) caps simultaneous outbound calls in `gateway.make_call`. It's checked after
|
||||
the emergency guard, before creating the call. Don't remove it or move it
|
||||
below call creation.
|
||||
|
||||
- **The Sippy thread boundary is crossed only through the two funnels.** Sippy
|
||||
UA objects and the `ED2` dispatcher live on the Sippy thread; legs, bridges,
|
||||
and the event bus live on the asyncio loop. Cross **thread→loop** only via
|
||||
`_post_from_ed` (→ `run_coroutine_threadsafe` → the single `_on_engine_event`
|
||||
funnel) and **loop→thread** only via `_run_on_sippy` (→ `ED2.callFromThread`).
|
||||
Never touch a Sippy UA object from the loop; never mutate loop-owned state from
|
||||
a Sippy handler. This is the whole reason the app is thread-safe.
|
||||
|
||||
- **Auth is Casdoor SSO + owner-minted PATs, owner-only, one resolver.** The
|
||||
browser signs in via Casdoor (short-lived JWT); MCP/CLI clients use owner-minted
|
||||
PATs (`hs_pat_…`). `resolve_bearer` in [auth.py](auth.py) turns either into a
|
||||
`User`, and `is_owner`/`get_current_owner` gate every surface to the single
|
||||
operator (`OWNER_NAME`) — non-owners get 403. Dev mode (`CASDOOR_ENABLED=false`)
|
||||
resolves the dev owner and is **only** permitted on a loopback bind;
|
||||
`_check_startup_config` refuses SSO-off-loopback and SSO-on-with-missing-config.
|
||||
Keep those refusals — a silent dev-owner-open-on-0.0.0.0 is the failure mode
|
||||
they exist to prevent. See [the auth rule](.claude/rules/auth-surfaces.md).
|
||||
|
||||
- **`/health` tells the truth.** `healthy` requires a real (non-mock) engine, a
|
||||
registered trunk, and a reachable DB. Don't relax it to make a probe go green;
|
||||
a gateway that can't place calls is not healthy, and the dashboard/operator
|
||||
needs to see that.
|
||||
|
||||
- **The database is the source of truth for history; live state is in memory.**
|
||||
Active calls live in the `CallManager`; completed calls + transcripts are
|
||||
persisted on hangup via `call_persistence`. MCP/REST history and summaries read
|
||||
from the DB through `session_scope()`. Don't confuse the two — a call that
|
||||
ended is gone from `active_calls` and only exists in the DB.
|
||||
|
||||
---
|
||||
|
||||
## Config — pydantic-settings, nested, singleton
|
||||
|
||||
Config is `Settings` in [config.py](config.py): a root `BaseSettings` with
|
||||
**nested sub-config models** (`SIPTrunkSettings`, `LLMSettings`, `TTSSettings`,
|
||||
`ReceptionistSettings`, …), each with its own `env_prefix`
|
||||
(`SIP_TRUNK_`, `LLM_`, `TTS_`, `RECEPTIONIST_`, …). Read config through
|
||||
`get_settings()` (a cached singleton) — don't call `os.environ.get` for
|
||||
Hold Slayer settings, and don't construct `Settings()` yourself outside that
|
||||
accessor.
|
||||
|
||||
> **Estate note (not a defect):** unlike the `KERNOS_`/`ARGOS_`/`NIKE_`-style
|
||||
> single-prefix services, Hold Slayer has **no one umbrella prefix** — root vars
|
||||
> (`DATABASE_URL`, `HOST`, `MAX_CONCURRENT_CALLS`, `OWNER_NAME`) are unprefixed
|
||||
> and each subsystem carries its own (`CASDOOR_` for the SSO connection). That's a
|
||||
> deliberate readability choice for
|
||||
> a config with this many subsystems; keep new vars consistent with the
|
||||
> sub-config they belong to, and if you add a new subsystem, give it its own
|
||||
> sub-config + prefix rather than piling flat vars onto the root.
|
||||
|
||||
Secrets (`casdoor.client_secret`, `sip_trunk.password`, `llm.api_key`,
|
||||
`tts.api_key`) are
|
||||
`SecretStr` — keep them so, and read via `.get_secret_value()` only at the point
|
||||
of use. Every new var also needs a row in [.env.example](.env.example) and the
|
||||
README's config table. See [the config rule](.claude/rules/config-startup.md).
|
||||
|
||||
**`.env` hygiene:** `.env` is gitignored and holds real secrets — never commit
|
||||
it, never treat the local `.env` as a template. Only `.env.example`
|
||||
(placeholders) is committed.
|
||||
|
||||
---
|
||||
|
||||
## MCP tools — formatted strings, and the error convention to know
|
||||
|
||||
MCP tools ([mcp_server/server.py](mcp_server/server.py)) return **plain
|
||||
human-readable strings** (not JSON, not Pydantic models — that's the REST
|
||||
layer's job). Resources (`gateway://status`, `gateway://call-flows`,
|
||||
`gateway://active-calls`) return JSON strings.
|
||||
|
||||
There's a **deliberate but uneven error convention** worth understanding before
|
||||
you add a tool:
|
||||
|
||||
- `make_call` raises `ToolError` on a bad request (emergency number, bad mode,
|
||||
cap hit) — a hard failure the assistant should treat as an error.
|
||||
- Most read/lookup tools **return** an error *string* (`"Call … not found."`,
|
||||
`"Error looking up …: {e}"`) instead of raising — a soft "here's what
|
||||
happened" the assistant reads as content.
|
||||
|
||||
When you add a tool: raise `ToolError` for "you asked for something invalid or
|
||||
unsafe"; return a plain string for "I looked and here's the (possibly empty)
|
||||
answer." The gateway is resolved lazily per call via `require_gateway()` (it
|
||||
raises `ToolError` while the gateway is still starting) — keep that, because the
|
||||
MCP app is mounted before the lifespan builds the gateway. See
|
||||
[the MCP rule](.claude/rules/mcp-tools.md).
|
||||
|
||||
---
|
||||
|
||||
## What's real vs. stubbed (don't mistake one for the other)
|
||||
|
||||
- **PJSUA2 media pipeline runs in stub mode** unless the `pjsua2` bindings are
|
||||
built from pjproject (not pip-installable). Signaling works; audio
|
||||
routing/recording is a no-op stub without them. Code that assumes real audio
|
||||
must degrade honestly, and `/health`/engine-mode must reflect stub vs real.
|
||||
- **The mock SIP engine (`MockSIPEngine`) must be asked for** (`USE_MOCK_SIP=true`).
|
||||
It exists for tests and local dev. Production must not silently run on it —
|
||||
`/health` reports `engine: mock` and refuses `healthy`.
|
||||
|
||||
---
|
||||
|
||||
## Known gaps (flag, don't fold — these are not your task unless asked)
|
||||
|
||||
Surfaced honestly so you don't rediscover them as surprises. The README's
|
||||
Phase 4/5/6 checklists track most of these; **don't fold fixes into unrelated
|
||||
work** — raise them.
|
||||
|
||||
- ~~**No structured JSON logging.**~~ Done: `LOG_FORMAT=json` in
|
||||
[core/logging_config.py](core/logging_config.py), applied at import and again
|
||||
in `lifespan` because uvicorn installs its own handlers (`propagate=False`)
|
||||
after importing the app. The access log is included, with `status_code` as a
|
||||
number so Loki can range-filter it. Text remains the default; the Docker image
|
||||
sets json.
|
||||
- **No `/metrics` endpoint and no Prometheus.** Unlike the metrics-bearing
|
||||
estate services, there's no exposition endpoint here yet.
|
||||
- **No health-probe access-log filter.** Every `/health` poll hits the access
|
||||
log. Other estate services suppress probe noise; this one doesn't.
|
||||
- ~~**No rate limiting** on API endpoints.~~ Done, but *narrowly*: only the
|
||||
unauthenticated `/auth/*` routes are limited
|
||||
([core/rate_limit.py](core/rate_limit.py)), because every other surface is
|
||||
already owner-gated and a limit there would throttle the sole operator. The
|
||||
limiter keys on the **socket peer, not `X-Forwarded-For`** — behind the
|
||||
estate's reverse proxy that means per-proxy, not per-caller. Per-caller limits
|
||||
need an explicit trusted-proxy config; don't silently start trusting the
|
||||
header.
|
||||
- **Docker: single-image `Dockerfile` + `docker-compose.yaml`** (app +
|
||||
`postgres:17`) ship in-repo; the Gitea CI (`cve-scan-docker-build.yml`) builds
|
||||
the image on push to `main`. The compose stack requires SSO enabled
|
||||
(published port ⇒ 0.0.0.0 ⇒ dev-owner mode refused). No systemd unit in-repo.
|
||||
- **A committed `.DS_Store` is *not* present** (good), but do check `git status`
|
||||
stays clean of OS cruft; `.gitignore` already lists it.
|
||||
|
||||
---
|
||||
|
||||
## Working here
|
||||
|
||||
- **Run:** `uvicorn main:app --host 0.0.0.0 --port 8000` (or `python main.py`).
|
||||
The CLI `--port` wins over `settings.port` in the startup banner logic — a real
|
||||
gotcha the banner code already accounts for; don't "fix" it into disagreement.
|
||||
- **Test:** `pytest tests/ -v`. Fast, no external services (SQLite + mock SIP).
|
||||
- **Lint:** `ruff check .` (line length 100, `py312` target).
|
||||
- **Dashboard:** `cd dashboard && npm install && npm run build` → served at `/`
|
||||
when `dashboard/build/` exists. The API/WS/health/MCP routes are registered
|
||||
**before** the `"/"` static mount because a root mount matches everything —
|
||||
keep that ordering.
|
||||
- **DB migrations:** Alembic (`db/migrations/`), upgrade-on-boot via `init_db`.
|
||||
Schema changes are migrations, never ad-hoc `CREATE`.
|
||||
|
||||
Path-scoped rules in [.claude/rules/](.claude/rules/) load automatically when you
|
||||
edit the files they cover. They carry the fine-grained "don't break this"
|
||||
detail; this file is the map.
|
||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
# Hold Slayer — single image: FastAPI process that also serves the built
|
||||
# SvelteKit dashboard at "/". One container, four surfaces (REST/WS/MCP/dash).
|
||||
#
|
||||
# pjsua2 is deliberately NOT built here — it is not pip-installable (compiled
|
||||
# from pjproject) and the media pipeline degrades to documented stub mode
|
||||
# without it. That is correct for a mock-SIP dev deploy (USE_MOCK_SIP=true);
|
||||
# /health honestly reports the mock engine as "degraded". Building real media
|
||||
# is a separate, deliberate piece of work.
|
||||
|
||||
# Stage 1: build the SvelteKit dashboard → dashboard/build/ (SPA, static).
|
||||
# dashboard/build and dashboard/node_modules are gitignored, so build fresh
|
||||
# here rather than copying a stale working-tree artifact.
|
||||
FROM node:22-alpine AS dashboard
|
||||
WORKDIR /dashboard
|
||||
COPY dashboard/package.json dashboard/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY dashboard/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: runtime. The app runs FROM SOURCE at /app (not purely from
|
||||
# site-packages): db/database.py locates alembic.ini via
|
||||
# Path(__file__).parent.parent, and main.py/config.py are loose top-level
|
||||
# modules — both require the source tree layout under the working dir. An
|
||||
# editable install puts the deps + entry points in place while keeping /app/db,
|
||||
# /app/config.py, /app/alembic.ini resolving to the real files.
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
# build-essential: some deps compile from source (no manylinux wheel).
|
||||
# curl: required for the compose healthcheck (GET /health).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies first (better layer caching) using just the manifest,
|
||||
# then the source. -e keeps the package importable from /app so alembic.ini
|
||||
# and the loose modules resolve correctly at runtime.
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY . .
|
||||
# Bring in the freshly built dashboard (overwrites any stale gitignored copy).
|
||||
COPY --from=dashboard /dashboard/build ./dashboard/build
|
||||
|
||||
RUN pip install --no-cache-dir -e . \
|
||||
&& apt-get purge -y build-essential && apt-get autoremove -y
|
||||
|
||||
EXPOSE 21081
|
||||
|
||||
# Structured logs by default in the container: the host's Alloy agent reads
|
||||
# stdout and ships it to Loki, where text lines arrive as an unqueryable blob.
|
||||
# Overridable (LOG_FORMAT=text) for interactive `docker run` debugging.
|
||||
ENV LOG_FORMAT=json
|
||||
|
||||
# Migrations run in the app's own init_db() on boot (db/database.py), so no
|
||||
# separate `alembic upgrade` here. Bind host/port from the same env vars
|
||||
# pydantic-settings reads (HOST/PORT) so configured values and the actual bind
|
||||
# cannot drift. Deploy sets PORT=21081 (image default 8000 collides with other
|
||||
# host-net services on triton).
|
||||
CMD ["sh", "-c", "uvicorn main:app --host ${HOST:-0.0.0.0} --port ${PORT:-21081}"]
|
||||
112
README.md
112
README.md
@@ -52,8 +52,9 @@ You give it a phone number and an intent ("dispute a charge on my December state
|
||||
## What's Implemented
|
||||
|
||||
### Core Engine
|
||||
- **Sippy B2BUA Engine** (`core/sippy_engine.py`) — SIP call control, DTMF, bridging, conference, trunk registration
|
||||
- **PJSUA2 Media Pipeline** (`core/media_pipeline.py`) — Audio routing, recording ports, conference bridge, WAV playback (stub mode until the `pjsua2` bindings are installed — see note below)
|
||||
- **Sippy B2BUA Engine** (`core/sippy_engine.py`) — SIP call control, DTMF, bridging, conference, trunk registration. Signalling only: no audio reaches the classifier on this path
|
||||
- **PJSUA2 SIP Engine** (`core/pjsua_engine.py`) — Places the call itself so it owns the dialog, which is the only way PJSUA2 will surface RTP. Select with `SIP_ENGINE=pjsua2`; see [docs/architecture.md](docs/architecture.md#media-plane-why-pjsua2-places-the-call)
|
||||
- **PJSUA2 Media Pipeline** (`core/media_pipeline.py`) — Audio routing, capture ports, conference bridge, WAV playback (stub mode until the `pjsua2` bindings are installed — see note below)
|
||||
- **Call Manager** (`core/call_manager.py`) — Active call state tracking, lifecycle management
|
||||
- **Event Bus** (`core/event_bus.py`) — Async pub/sub with per-subscriber queues, type filtering, history
|
||||
|
||||
@@ -99,8 +100,13 @@ hold-slayer/
|
||||
├── config.py # Pydantic settings from .env
|
||||
├── core/
|
||||
│ ├── gateway.py # Top-level gateway orchestrator
|
||||
│ ├── sippy_engine.py # Sippy B2BUA SIP engine
|
||||
│ ├── dial_plan.py # Emergency-number guard + number normalisation
|
||||
│ ├── sip_engine.py # SIPEngine ABC + MockSIPEngine
|
||||
│ ├── sippy_engine.py # Sippy B2BUA SIP engine (signalling only)
|
||||
│ ├── pjsua_engine.py # PJSUA2 SIP engine (call control + media)
|
||||
│ ├── media_pipeline.py # PJSUA2 audio routing
|
||||
│ ├── logging_config.py # Text/JSON log formatting
|
||||
│ ├── rate_limit.py # Fixed-window limiter for the /auth/* edge
|
||||
│ ├── call_manager.py # Active call state management
|
||||
│ └── event_bus.py # Async pub/sub event bus
|
||||
├── services/
|
||||
@@ -165,20 +171,29 @@ pip install -e ".[dev]"
|
||||
> The PJSUA2 media pipeline needs the `pjsua2` Python bindings, which are
|
||||
> **not pip-installable** — they're built from pjproject (`./configure &&
|
||||
> make && make install` with `--enable-shared` and the Python SWIG target).
|
||||
> Without them the media layer runs in stub mode (signaling only).
|
||||
> Without them the media layer runs in stub mode (signaling only): audio
|
||||
> routing, recording and playback become no-ops that still return success.
|
||||
>
|
||||
> **See [docs/pjsua2-build.md](docs/pjsua2-build.md)** for the verified
|
||||
> procedure (pjproject 2.17, no `sudo` required). It includes the `patchelf`
|
||||
> RPATH step, without which the bindings compile and install but fail to import.
|
||||
|
||||
### 2. Configure
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your SIP trunk credentials, LLM endpoint, etc.
|
||||
# Required: DATABASE_URL, and API_TOKEN unless HOST=127.0.0.1
|
||||
openssl rand -hex 32 # → API_TOKEN
|
||||
# Required: DATABASE_URL, plus either the Casdoor SSO settings
|
||||
# (CASDOOR_* + OWNER_NAME) or CASDOOR_ENABLED=false with HOST=127.0.0.1.
|
||||
```
|
||||
|
||||
All REST, WebSocket, and MCP access requires `Authorization: Bearer
|
||||
$API_TOKEN` (WebSocket also accepts `?token=...`). An empty token is only
|
||||
permitted when bound to loopback.
|
||||
The gateway is **owner-only**. The browser dashboard signs in via **Casdoor
|
||||
SSO** (short-lived JWT); MCP and CLI clients use a **Personal Access Token**
|
||||
(`hs_pat_…`) minted from the dashboard's *API Tokens* menu. Both are presented as
|
||||
`Authorization: Bearer <token>` (WebSocket and `<audio>` recording downloads also
|
||||
accept `?token=…`). Only the user whose Casdoor username matches `OWNER_NAME` may
|
||||
use any surface — everyone else gets 403. With `CASDOOR_ENABLED=false` the gateway
|
||||
runs in dev-owner mode, permitted **only** on a loopback bind.
|
||||
|
||||
### 3. Build the dashboard (optional but recommended)
|
||||
|
||||
@@ -204,6 +219,36 @@ uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
No external services required — the suite runs against SQLite (`aiosqlite`) and
|
||||
the mock SIP engine, so it needs neither a trunk nor PostgreSQL.
|
||||
|
||||
For the parts a unit test cannot reach — real SIP signalling, RTP, IVR
|
||||
navigation against a live switch — there is an **Asterisk lab**: a fake PSTN
|
||||
that answers calls, plays hold music, and runs scripted IVR menus, so call
|
||||
paths can be exercised without dialling a real number or incurring telephony
|
||||
charges. See [tests/lab/README.md](tests/lab/README.md).
|
||||
|
||||
## Docker
|
||||
|
||||
A single image bundles the FastAPI process and the built dashboard (the node
|
||||
stage compiles the SPA; `pjsua2` is deliberately not built, so the media
|
||||
pipeline runs in stub mode — see the `Dockerfile` header). `docker-compose.yaml`
|
||||
brings up the app plus its own PostgreSQL:
|
||||
|
||||
```bash
|
||||
cp .env.compose.example .env
|
||||
# Fill in HS_DB_PASSWORD, and CASDOOR_CLIENT_ID/SECRET + OWNER_NAME.
|
||||
docker compose up --build
|
||||
# → http://localhost:21081
|
||||
```
|
||||
|
||||
Because the published port binds the app to `0.0.0.0`, the compose stack must run
|
||||
with **Casdoor SSO enabled** — dev-owner mode (`CASDOOR_ENABLED=false`) is
|
||||
loopback-only and is refused at startup here. Register a `hold-slayer` app in
|
||||
Casdoor (org `heluca`, redirect URI `<PUBLIC_BASE_URL>/auth/callback`) first. The
|
||||
image runs with `USE_MOCK_SIP=true` by default (a real trunk needs the
|
||||
`SIP_TRUNK_*` vars and `USE_MOCK_SIP=false`).
|
||||
|
||||
## Usage
|
||||
|
||||
### REST API
|
||||
@@ -212,7 +257,7 @@ pytest tests/ -v
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/calls/hold-slayer \
|
||||
-H "Authorization: Bearer $API_TOKEN" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"number": "+18005551234",
|
||||
@@ -264,7 +309,7 @@ curl -X PATCH http://localhost:8000/api/v1/routing/devices/dev_abc123/dnd \
|
||||
### WebSocket — Real-Time Events
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket(`ws://localhost:8000/ws/events?token=${API_TOKEN}`);
|
||||
const ws = new WebSocket(`ws://localhost:8000/ws/events?token=${token}`);
|
||||
ws.onmessage = (msg) => {
|
||||
const event = JSON.parse(msg.data);
|
||||
// event.type: "human_detected", "hold_detected", "ivr_step", etc.
|
||||
@@ -276,11 +321,12 @@ ws.onmessage = (msg) => {
|
||||
### MCP — AI Assistant Integration
|
||||
|
||||
The MCP server is served over **streamable HTTP at `/mcp/`** (note the
|
||||
trailing slash) and authenticates with the same bearer token:
|
||||
trailing slash) and authenticates with an owner-minted Personal Access Token
|
||||
(mint one from the dashboard's *API Tokens* menu — it starts with `hs_pat_`):
|
||||
|
||||
```bash
|
||||
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
|
||||
--header "Authorization: Bearer $API_TOKEN"
|
||||
--header "Authorization: Bearer hs_pat_..."
|
||||
```
|
||||
|
||||
It exposes 15 tools and 3 resources (`gateway://status`,
|
||||
@@ -333,13 +379,28 @@ All configuration is via environment variables (see `.env.example`):
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | — (required) |
|
||||
| `API_TOKEN` | Static bearer token for REST/WS/MCP | — (required unless `HOST=127.0.0.1`) |
|
||||
| `CASDOOR_ENABLED` | Enable Casdoor SSO (false → dev-owner, loopback only) | `false` |
|
||||
| `CASDOOR_ENDPOINT` | Casdoor base URL | `https://id.ouranos.helu.ca` |
|
||||
| `CASDOOR_CLIENT_ID` | Casdoor application client ID | — (required if SSO on) |
|
||||
| `CASDOOR_CLIENT_SECRET` | Casdoor application client secret | — (required if SSO on) |
|
||||
| `CASDOOR_ORG_NAME` | Casdoor organization | `heluca` |
|
||||
| `CASDOOR_APP_NAME` | Casdoor application name | — |
|
||||
| `OWNER_NAME` | Casdoor username of the single operator (owner) | — (required if SSO on) |
|
||||
| `PUBLIC_BASE_URL` | Public base URL for OAuth discovery (else derived from headers) | — |
|
||||
| `MAX_CONCURRENT_CALLS` | Cap on simultaneous outbound calls | `4` |
|
||||
| `HOST` | Bind address (off-loopback requires SSO enabled) | `0.0.0.0` |
|
||||
| `PORT` | Bind port | `8000` |
|
||||
| `DEBUG` | SQLAlchemy echo + uvicorn reload | `false` |
|
||||
| `LOG_LEVEL` | Root log level (`debug`/`info`/`warning`/`error`) | `info` |
|
||||
| `LOG_FORMAT` | `text` (human-readable) or `json` (structured, for Loki) | `text` |
|
||||
| `USE_MOCK_SIP` | Run the mock SIP engine — no real calls. Must be asked for | `false` |
|
||||
| `SIP_ENGINE` | `sippy` (signalling only) or `pjsua2` (call control + media) | `sippy` |
|
||||
| `NOTIFY_SMS_NUMBER` | SMS notification number (optional) | — |
|
||||
| `SIP_TRUNK_HOST` | Your SIP provider hostname | — |
|
||||
| `SIP_TRUNK_USERNAME` | SIP auth username | — |
|
||||
| `SIP_TRUNK_PASSWORD` | SIP auth password | — |
|
||||
| `SIP_TRUNK_DID` | Your phone number (E.164) | — |
|
||||
| `GATEWAY_SIP_PORT` | Port for device registration | `5080` |
|
||||
| `GATEWAY_SIP_PORT` | Port for device registration | `5060` |
|
||||
| `SPEACHES_URL` | Speaches/Whisper STT endpoint | `http://localhost:22070` |
|
||||
| `LLM_BASE_URL` | OpenAI-compatible LLM endpoint | `http://localhost:11434/v1` |
|
||||
| `LLM_MODEL` | Model name for IVR analysis | `llama3` |
|
||||
@@ -353,11 +414,11 @@ All configuration is via environment variables (see `.env.example`):
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Python 3.12+** + **asyncio** — Single-process async architecture
|
||||
- **Python 3.12+** + **asyncio** — Single process. Not single-threaded: the SIP stacks run their own event loops on separate OS threads, crossed only through defined funnels ([docs/architecture.md](docs/architecture.md#threading-model))
|
||||
- **FastAPI** — REST API + WebSocket server
|
||||
- **SvelteKit** — Dashboard UI (built static, served by FastAPI at `/`)
|
||||
- **Sippy B2BUA** — SIP call control and DTMF
|
||||
- **PJSUA2** — Media pipeline, conference bridge, recording, WAV playback
|
||||
- **Sippy B2BUA** — SIP call control and DTMF (`SIP_ENGINE=sippy`, signalling only)
|
||||
- **PJSUA2** — Call control + media: conference bridge, capture ports, recording, WAV playback (`SIP_ENGINE=pjsua2`)
|
||||
- **Speaches** (Whisper) — Speech-to-text
|
||||
- **Rhema** (Kokoro) — Text-to-speech (OpenAI-compatible `/v1/audio/speech`)
|
||||
- **Ollama / vLLM / OpenAI** — LLM for IVR menu analysis and receptionist intent capture
|
||||
@@ -370,6 +431,9 @@ Full documentation is in [`/docs`](docs/README.md):
|
||||
|
||||
- [Architecture](docs/architecture.md) — System design, data flow, threading model
|
||||
- [Core Engine](docs/core-engine.md) — SIP engine, media pipeline, call manager, event bus
|
||||
- [Dial Plan](docs/dial-plan.md) — Number normalisation and the emergency-number guard
|
||||
- [PJSUA2 Build](docs/pjsua2-build.md) — Building the bindings (not pip-installable)
|
||||
- [Asterisk Lab](tests/lab/README.md) — The fake PSTN used for media validation
|
||||
- [Hold Slayer Service](docs/hold-slayer-service.md) — IVR navigation, hold detection, human detection
|
||||
- [Audio Classifier](docs/audio-classifier.md) — Waveform analysis, feature extraction, classification
|
||||
- [Services](docs/services.md) — LLM client, transcription, recording, analytics, notifications
|
||||
@@ -405,16 +469,16 @@ Full documentation is in [`/docs`](docs/README.md):
|
||||
- [x] Notification service (WebSocket + SMS)
|
||||
- [x] Service wiring in main.py lifespan
|
||||
|
||||
### Phase 4: Production Hardening 🚧
|
||||
### Phase 4: Production Hardening ✅
|
||||
|
||||
- [x] Alembic database migrations (baseline + upgrade-on-boot)
|
||||
- [x] API authentication — static bearer token across REST/WS/MCP
|
||||
- [x] API authentication — Casdoor SSO (browser JWT) + owner-minted PATs, owner-only across REST/WS/MCP
|
||||
- [x] Emergency-number guard + concurrent-call cap on outbound calls
|
||||
- [ ] Rate limiting on API endpoints
|
||||
- [ ] Structured JSON logging
|
||||
- [x] Rate limiting on the unauthenticated `/auth/*` edge (everything else is owner-gated; see [core/rate_limit.py](core/rate_limit.py))
|
||||
- [x] Structured JSON logging (`LOG_FORMAT=json`, uvicorn access log included)
|
||||
- [x] Honest /health — engine mode, DB ping, trunk registration, STT/TTS availability
|
||||
- [ ] Graceful degradation (classifier works without STT, etc.)
|
||||
- [ ] Docker Compose (Hold Slayer + PostgreSQL)
|
||||
- [x] Graceful degradation — a down STT/LLM/TTS degrades the call and publishes an `ERROR` event naming the service, rather than aborting it
|
||||
- [x] Docker Compose (Hold Slayer + PostgreSQL)
|
||||
|
||||
### Phase 5: Additional Services 🚧
|
||||
|
||||
|
||||
201
api/auth.py
Normal file
201
api/auth.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
OIDC authentication endpoints (Casdoor SSO).
|
||||
|
||||
GET /auth/login → redirect to Casdoor authorization URL
|
||||
GET /auth/callback → exchange code for tokens, redirect to UI with token
|
||||
GET /auth/me → return current user info (requires Bearer token)
|
||||
GET /auth/silent-refresh → hidden-iframe refresh (re-auth with existing session)
|
||||
GET /auth/refresh-callback → post the refreshed token to the parent window
|
||||
GET /auth/logout → redirect to Casdoor logout URL
|
||||
|
||||
The dashboard is owner-only; ``/auth/me`` returns ``is_owner`` so a signed-in
|
||||
non-owner sees an "access denied" screen instead of a bare 401.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from auth import get_sdk, is_owner, resolve_from_header_or_query
|
||||
from config import get_settings
|
||||
from core.rate_limit import rate_limit
|
||||
from db.database import session_scope
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
# These are the only routes that must answer before an identity exists, so they
|
||||
# are the only ones worth limiting — everything else is already owner-gated.
|
||||
# `/callback` and `/refresh-callback` each trigger an outbound token exchange
|
||||
# with Casdoor, and `/me` opens a DB session per call; all three are
|
||||
# unauthenticated work an attacker controls. See core/rate_limit.py.
|
||||
_limit_callback = [Depends(rate_limit("auth:callback", limit=10))]
|
||||
_limit_me = [Depends(rate_limit("auth:me"))]
|
||||
_limit_redirect = [Depends(rate_limit("auth:redirect"))]
|
||||
|
||||
|
||||
def _build_casdoor_auth_url(
|
||||
callback: str,
|
||||
*,
|
||||
scope: str = "openid profile email",
|
||||
state: str | None = None,
|
||||
prompt: str | None = None,
|
||||
) -> str:
|
||||
"""Build the Casdoor authorization URL directly.
|
||||
|
||||
The SDK's get_auth_link() doesn't support the ``prompt`` parameter that
|
||||
silent refresh needs, so build the URL manually.
|
||||
"""
|
||||
c = get_settings().casdoor
|
||||
params = {
|
||||
"client_id": c.client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": callback,
|
||||
"scope": scope,
|
||||
"state": state or secrets.token_urlsafe(16),
|
||||
}
|
||||
if prompt:
|
||||
params["prompt"] = prompt
|
||||
return f"{c.endpoint.rstrip('/')}/login/oauth/authorize?{urlencode(params)}"
|
||||
|
||||
|
||||
@router.get("/login", dependencies=_limit_redirect)
|
||||
async def login(request: Request, redirect_uri: str = Query(None)):
|
||||
"""Redirect the browser to the Casdoor authorization page.
|
||||
|
||||
No ``prompt=login`` — an existing Casdoor session auto-redirects back with
|
||||
a code without showing the login form (silent SSO across *.helu.ca).
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
callback = redirect_uri or f"{request.base_url}auth/callback"
|
||||
return RedirectResponse(url=_build_casdoor_auth_url(callback))
|
||||
|
||||
|
||||
@router.get("/callback", dependencies=_limit_callback)
|
||||
async def callback(
|
||||
code: str = Query(...),
|
||||
state: str = Query(None),
|
||||
redirect_uri: str = Query(None),
|
||||
):
|
||||
"""Exchange the authorization code for tokens.
|
||||
|
||||
Redirects to the dashboard with the access token in the URL *fragment*
|
||||
(``/#token=...``) so the token stays client-side and is stored in
|
||||
localStorage.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
sdk = get_sdk()
|
||||
try:
|
||||
token = await sdk.get_oauth_token(code=code)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Token exchange failed: {exc}") from exc
|
||||
|
||||
access_token = token.get("access_token", "")
|
||||
return RedirectResponse(url=f"/#token={access_token}")
|
||||
|
||||
|
||||
@router.get("/silent-refresh", dependencies=_limit_redirect)
|
||||
async def silent_refresh(request: Request):
|
||||
"""Start a silent token refresh via hidden iframe (``prompt=none``).
|
||||
|
||||
If the Casdoor session is still active, Casdoor redirects back to
|
||||
``/auth/refresh-callback`` with a fresh code — no login form. Otherwise it
|
||||
returns an error and the iframe tells the parent to show the login overlay.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
callback = f"{request.base_url}auth/refresh-callback"
|
||||
return RedirectResponse(url=_build_casdoor_auth_url(callback, prompt="none"))
|
||||
|
||||
|
||||
@router.get("/refresh-callback", dependencies=_limit_callback)
|
||||
async def refresh_callback(
|
||||
code: str = Query(None),
|
||||
error: str = Query(None),
|
||||
state: str = Query(None),
|
||||
):
|
||||
"""Handle the silent-refresh callback inside the hidden iframe.
|
||||
|
||||
On success posts the new token to the parent window; on failure posts an
|
||||
error so the parent shows the login overlay.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
raise HTTPException(400, "Casdoor SSO is not enabled")
|
||||
|
||||
if error or not code:
|
||||
return HTMLResponse(
|
||||
'<script>window.parent.postMessage('
|
||||
'{type:"hold-slayer-refresh",error:true},"*");</script>'
|
||||
)
|
||||
|
||||
sdk = get_sdk()
|
||||
try:
|
||||
token = await sdk.get_oauth_token(code=code)
|
||||
access_token = token.get("access_token", "")
|
||||
except Exception:
|
||||
return HTMLResponse(
|
||||
'<script>window.parent.postMessage('
|
||||
'{type:"hold-slayer-refresh",error:true},"*");</script>'
|
||||
)
|
||||
|
||||
return HTMLResponse(
|
||||
f'<script>window.parent.postMessage('
|
||||
f'{{type:"hold-slayer-refresh",token:"{access_token}"}},"*");</script>'
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", dependencies=_limit_me)
|
||||
async def me(request: Request):
|
||||
"""Return the current authenticated user's profile + ``is_owner``.
|
||||
|
||||
Resolved manually (not via the ``OwnerUser`` gate) so a signed-in
|
||||
non-owner gets a 200 with ``is_owner:false`` — the dashboard uses that to
|
||||
show the "not authorized" screen rather than treating it as a hard 401.
|
||||
"""
|
||||
auth_header = request.headers.get("authorization")
|
||||
q_token = request.query_params.get("token")
|
||||
|
||||
async with session_scope() as session:
|
||||
user = await resolve_from_header_or_query(session, auth_header, q_token)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": user.id,
|
||||
"name": user.name,
|
||||
"display_name": user.display_name,
|
||||
"email": user.email,
|
||||
"is_owner": is_owner(user),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Clear the Casdoor session and redirect back to the app.
|
||||
|
||||
``post_logout_redirect_uri`` must be absolute — Casdoor won't follow a
|
||||
relative ``/`` — so it's derived from ``request.base_url`` (works behind
|
||||
HAProxy/nginx with X-Forwarded-Proto/Host).
|
||||
"""
|
||||
c = get_settings().casdoor
|
||||
if not c.enabled:
|
||||
return RedirectResponse(url="/")
|
||||
|
||||
app_url = str(request.base_url).rstrip("/")
|
||||
logout_url = (
|
||||
f"{c.endpoint.rstrip('/')}/login/oauth/logout"
|
||||
f"?client_id={c.client_id}"
|
||||
f"&post_logout_redirect_uri={app_url}/auth/login"
|
||||
)
|
||||
return RedirectResponse(url=logout_url)
|
||||
36
api/deps.py
36
api/deps.py
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
API Dependencies — Shared dependency injection for all routes.
|
||||
|
||||
Auth is not here: the owner gate lives in `auth.py` (`get_current_owner` /
|
||||
`OwnerUser`), applied as a router-level dependency in main.py.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from fastapi import Header, HTTPException, Query, Request
|
||||
|
||||
from config import get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
|
||||
|
||||
@@ -24,31 +24,3 @@ def get_routing_service(request: Request):
|
||||
if routing is None:
|
||||
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||
return routing
|
||||
|
||||
|
||||
def require_token(
|
||||
authorization: str | None = Header(default=None),
|
||||
token: str | None = Query(default=None),
|
||||
) -> None:
|
||||
"""
|
||||
Enforce the static bearer token (API_TOKEN) on REST routes.
|
||||
|
||||
A `token` query parameter is accepted alongside the Authorization
|
||||
header for clients that can't set headers — <audio>/<a> elements
|
||||
fetching recordings — matching the WebSocket convention.
|
||||
|
||||
An empty configured token disables auth; startup refuses that
|
||||
combination unless the server is bound to loopback.
|
||||
"""
|
||||
expected = get_settings().api_token.get_secret_value()
|
||||
if not expected:
|
||||
return
|
||||
supplied = token or ""
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
supplied = authorization[7:]
|
||||
if not secrets.compare_digest(supplied, expected):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Missing or invalid bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
107
api/tokens.py
Normal file
107
api/tokens.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Owner-only CRUD for personal access tokens (PATs).
|
||||
|
||||
PATs are long-lived bearer tokens for MCP/CLI clients (Claude Desktop, Cline)
|
||||
and scripted API consumers that can't refresh a short-lived Casdoor JWT. The
|
||||
plaintext is shown to the caller exactly once at creation; only its SHA-256
|
||||
hash is stored.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import PAT_PREFIX, OwnerUser, hash_token
|
||||
from db.database import PersonalAccessToken, get_db
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tokens", tags=["tokens"])
|
||||
|
||||
|
||||
class TokenCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
token_prefix: str
|
||||
created_at: str | None = None
|
||||
last_used_at: str | None = None
|
||||
expires_at: str | None = None
|
||||
revoked_at: str | None = None
|
||||
|
||||
|
||||
class TokenCreated(TokenOut):
|
||||
token: str = Field(..., description="Plaintext token — shown only once. Store it now.")
|
||||
|
||||
|
||||
def _serialize(pat: PersonalAccessToken) -> dict:
|
||||
return {
|
||||
"id": pat.id,
|
||||
"name": pat.name,
|
||||
"token_prefix": pat.token_prefix,
|
||||
"created_at": pat.created_at.isoformat() if pat.created_at else None,
|
||||
"last_used_at": pat.last_used_at.isoformat() if pat.last_used_at else None,
|
||||
"expires_at": pat.expires_at.isoformat() if pat.expires_at else None,
|
||||
"revoked_at": pat.revoked_at.isoformat() if pat.revoked_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=list[TokenOut])
|
||||
async def list_tokens(
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""List the owner's personal access tokens (no plaintext)."""
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken)
|
||||
.where(PersonalAccessToken.user_id == user.id)
|
||||
.order_by(PersonalAccessToken.created_at.desc())
|
||||
)
|
||||
return [_serialize(pat) for pat in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("", response_model=TokenCreated, status_code=201)
|
||||
async def create_token(
|
||||
payload: TokenCreate,
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Mint a new PAT. The plaintext is returned ONCE in the response."""
|
||||
plaintext = PAT_PREFIX + secrets.token_urlsafe(32)
|
||||
pat = PersonalAccessToken(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id=user.id,
|
||||
name=payload.name,
|
||||
token_hash=hash_token(plaintext),
|
||||
token_prefix=plaintext[: len(PAT_PREFIX) + 4],
|
||||
)
|
||||
session.add(pat)
|
||||
await session.commit()
|
||||
await session.refresh(pat)
|
||||
return {**_serialize(pat), "token": plaintext}
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=204)
|
||||
async def revoke_token(
|
||||
token_id: str,
|
||||
user: OwnerUser,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Soft-revoke a PAT (sets revoked_at)."""
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken).where(
|
||||
PersonalAccessToken.id == token_id,
|
||||
PersonalAccessToken.user_id == user.id,
|
||||
)
|
||||
)
|
||||
pat = result.scalar_one_or_none()
|
||||
if pat is None:
|
||||
raise HTTPException(status_code=404, detail="Token not found")
|
||||
if pat.revoked_at is None:
|
||||
pat.revoked_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
@@ -1,13 +1,11 @@
|
||||
"""WebSocket API — Real-time call events and audio classification stream."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from api.deps import get_gateway
|
||||
from config import get_settings
|
||||
from auth import is_owner, resolve_from_header_or_query
|
||||
from db.database import session_scope
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,21 +15,21 @@ router = APIRouter()
|
||||
|
||||
async def _authorize(websocket: WebSocket) -> bool:
|
||||
"""
|
||||
Check the static bearer token before accepting the socket.
|
||||
Require the owner before accepting the socket.
|
||||
|
||||
Browsers can't set headers on WebSocket connects, so a `token`
|
||||
query parameter is accepted alongside the Authorization header.
|
||||
Browsers can't set headers on WebSocket connects, so the Casdoor JWT (or
|
||||
a PAT) is accepted on the `?token=` query param alongside the Authorization
|
||||
header — the same narrow fallback the recording download uses. In dev mode
|
||||
the owner resolves tokenlessly. A non-owner or absent credential closes the
|
||||
socket with code 4401.
|
||||
"""
|
||||
token = get_settings().api_token.get_secret_value()
|
||||
if not token:
|
||||
q_token = websocket.query_params.get("token")
|
||||
auth_header = websocket.headers.get("authorization")
|
||||
async with session_scope() as session:
|
||||
user = await resolve_from_header_or_query(session, auth_header, q_token)
|
||||
if user is not None and is_owner(user):
|
||||
return True
|
||||
supplied = websocket.query_params.get("token", "")
|
||||
auth = websocket.headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
supplied = auth[7:]
|
||||
if secrets.compare_digest(supplied, token):
|
||||
return True
|
||||
await websocket.close(code=4401, reason="Missing or invalid bearer token")
|
||||
await websocket.close(code=4401, reason="Owner authentication required")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
385
auth.py
Normal file
385
auth.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Authentication and authorisation for Hold Slayer.
|
||||
|
||||
This gateway is **owner-only**. It dials real phones and spends money, so
|
||||
there are no guest/shared resources: exactly one operator (the Casdoor user
|
||||
whose name matches ``OWNER_NAME``) may use any surface; every other identity
|
||||
gets 403.
|
||||
|
||||
Two bearer-token kinds are accepted on ``Authorization: Bearer <token>``
|
||||
(or, for the two browser consumers that can't set headers — the WebSocket
|
||||
connect and ``<audio>`` recording downloads — on a ``?token=`` query param):
|
||||
|
||||
1. **Casdoor JWT** — short-lived, signed by Casdoor. Validated against the
|
||||
public keys served at ``${CASDOOR_ENDPOINT}/.well-known/jwks`` (PyJWKClient
|
||||
cache, RS256). Used by the browser dashboard after OIDC login.
|
||||
2. **Personal Access Token** — long-lived ``hs_pat_<random>`` token, minted
|
||||
from the owner-only dashboard and stored hashed in
|
||||
``personal_access_tokens``. Used by MCP/CLI clients (Claude Desktop, Cline)
|
||||
that can't refresh a JWT.
|
||||
|
||||
When ``CASDOOR_ENABLED=false`` (dev, loopback only) every request resolves to
|
||||
the dev owner — no token required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
import jwt
|
||||
from casdoor import AsyncCasdoorSDK
|
||||
from fastapi import Depends, HTTPException, Query
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import get_settings
|
||||
from db.database import PersonalAccessToken, User, get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
_DEV_OWNER_SUB = "dev-owner"
|
||||
PAT_PREFIX = "hs_pat_"
|
||||
|
||||
# ── Casdoor SDK singleton (for OAuth code exchange in /auth/callback) ─────────
|
||||
|
||||
_sdk: AsyncCasdoorSDK | None = None
|
||||
|
||||
|
||||
def get_sdk() -> AsyncCasdoorSDK:
|
||||
"""Build the Casdoor SDK lazily.
|
||||
|
||||
Used only for the OAuth2 code-exchange step in ``/auth/callback`` — JWT
|
||||
validation happens via PyJWKClient below. The certificate parameter is
|
||||
unused for code exchange but the constructor requires *something*; we pass
|
||||
an empty bytestring.
|
||||
"""
|
||||
global _sdk
|
||||
if _sdk is None:
|
||||
c = get_settings().casdoor
|
||||
_sdk = AsyncCasdoorSDK(
|
||||
endpoint=c.endpoint,
|
||||
client_id=c.client_id,
|
||||
client_secret=c.client_secret.get_secret_value(),
|
||||
certificate=b"",
|
||||
org_name=c.org_name,
|
||||
application_name=c.app_name,
|
||||
)
|
||||
return _sdk
|
||||
|
||||
|
||||
# ── JWKS client (for Casdoor JWT validation) ─────────────────────────────────
|
||||
|
||||
_jwks_client: jwt.PyJWKClient | None = None
|
||||
|
||||
|
||||
def init_jwks_client() -> None:
|
||||
"""Construct the PyJWKClient pointed at Casdoor's JWKS endpoint.
|
||||
|
||||
Called once from the app lifespan before requests are served. Pre-fetches
|
||||
the keys so the network round-trip happens at startup rather than on the
|
||||
first authenticated request. A no-op when SSO is disabled; a failed
|
||||
prefetch is non-fatal (keys are fetched lazily on first use).
|
||||
"""
|
||||
global _jwks_client
|
||||
if not get_settings().casdoor.enabled:
|
||||
return
|
||||
endpoint = get_settings().casdoor.endpoint.rstrip("/")
|
||||
jwks_uri = f"{endpoint}/.well-known/jwks"
|
||||
_jwks_client = jwt.PyJWKClient(jwks_uri, cache_keys=True, lifespan=3600)
|
||||
try:
|
||||
_jwks_client.fetch_data()
|
||||
logger.info("Casdoor JWKS prefetched from %s", jwks_uri)
|
||||
except Exception as exc:
|
||||
logger.warning("Casdoor JWKS prefetch failed (%s); will retry on first request", exc)
|
||||
|
||||
|
||||
def _decode_casdoor_jwt(token: str) -> dict:
|
||||
"""Validate a Casdoor RS256 JWT against the cached JWKS.
|
||||
|
||||
Refreshes the key cache once on unknown-kid before giving up. Audience
|
||||
verification is disabled because Casdoor sets ``aud`` to the application
|
||||
name, which differs from the client_id; the signature check against
|
||||
Casdoor's key is the primary control.
|
||||
"""
|
||||
if _jwks_client is None:
|
||||
raise HTTPException(status_code=503, detail="Auth subsystem not ready")
|
||||
|
||||
issuer = get_settings().casdoor.endpoint.rstrip("/")
|
||||
|
||||
def _decode_with_current_keys() -> dict:
|
||||
signing_key = _jwks_client.get_signing_key_from_jwt(token)
|
||||
return jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=issuer,
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
|
||||
try:
|
||||
return _decode_with_current_keys()
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise HTTPException(status_code=401, detail="Token has expired") from exc
|
||||
except jwt.PyJWKClientError as exc:
|
||||
logger.warning("Unknown JWKS key (%s); refreshing", exc)
|
||||
try:
|
||||
_jwks_client.fetch_data()
|
||||
return _decode_with_current_keys()
|
||||
except Exception as inner:
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {inner}") from inner
|
||||
except jwt.InvalidTokenError as exc:
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {exc}") from exc
|
||||
|
||||
|
||||
# ── PAT helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def hash_token(plaintext: str) -> str:
|
||||
"""SHA-256 hex digest of a plaintext PAT."""
|
||||
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _validate_pat(session: AsyncSession, plaintext: str) -> User:
|
||||
"""Look up a PAT by hash, check it's active, return the owning user."""
|
||||
digest = hash_token(plaintext)
|
||||
result = await session.execute(
|
||||
select(PersonalAccessToken).where(PersonalAccessToken.token_hash == digest)
|
||||
)
|
||||
pat = result.scalar_one_or_none()
|
||||
if pat is None or pat.revoked_at is not None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
now = datetime.now(UTC)
|
||||
if pat.expires_at is not None:
|
||||
# DateTime columns come back naive on SQLite (and on a Postgres
|
||||
# TIMESTAMP without tz); treat a naive value as UTC before comparing.
|
||||
expires_at = pat.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
if expires_at <= now:
|
||||
raise HTTPException(status_code=401, detail="Token has expired")
|
||||
|
||||
pat.last_used_at = now
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
user_result = await session.execute(select(User).where(User.id == pat.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return user
|
||||
|
||||
|
||||
# ── User provisioning ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _get_or_create_dev_owner(session: AsyncSession) -> User:
|
||||
"""Return the dev-mode owner user row, creating it if it doesn't exist."""
|
||||
result = await session.execute(select(User).where(User.casdoor_sub == _DEV_OWNER_SUB))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(id=uuid.uuid4().hex, name="Owner", casdoor_sub=_DEV_OWNER_SUB)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def _find_or_create_user(
|
||||
session: AsyncSession,
|
||||
casdoor_sub: str,
|
||||
name: str,
|
||||
display_name: str,
|
||||
email: str | None,
|
||||
) -> User:
|
||||
"""Look up a user by casdoor_sub; create a new row on first login.
|
||||
|
||||
Lookup priority, so identity survives a Casdoor redeploy:
|
||||
1. casdoor_sub — the OIDC subject claim (primary SSO identity).
|
||||
2. name — the Casdoor username (stable, unique). Relinks a changed sub.
|
||||
3. email — pre-SSO users logging in via Casdoor for the first time.
|
||||
|
||||
Non-owner users are still provisioned (so ``is_owner`` can say "no"), but
|
||||
they reach nothing — every surface is owner-gated.
|
||||
"""
|
||||
result = await session.execute(select(User).where(User.casdoor_sub == casdoor_sub))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
changed = False
|
||||
if user.name != name:
|
||||
user.name = name
|
||||
changed = True
|
||||
if user.display_name != display_name:
|
||||
user.display_name = display_name
|
||||
changed = True
|
||||
if changed:
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
result = await session.execute(select(User).where(User.name == name))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
logger.info(
|
||||
"Linking user %s (id=%s) to new casdoor_sub %s (was %s)",
|
||||
name, user.id, casdoor_sub, user.casdoor_sub,
|
||||
)
|
||||
user.casdoor_sub = casdoor_sub
|
||||
user.display_name = display_name
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
if email:
|
||||
result = await session.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is not None:
|
||||
user.casdoor_sub = casdoor_sub
|
||||
user.name = name
|
||||
user.display_name = display_name
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4().hex,
|
||||
name=name,
|
||||
display_name=display_name,
|
||||
email=email,
|
||||
casdoor_sub=casdoor_sub,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
logger.info("Created new user: %s (id=%s)", name, user.id)
|
||||
return user
|
||||
|
||||
|
||||
def _claims_to_identity(claims: dict) -> tuple[str, str, str, str | None]:
|
||||
"""Pull (sub, name, display_name, email) out of Casdoor JWT claims."""
|
||||
sub = claims.get("sub") or claims.get("name") or ""
|
||||
name = claims.get("name") or sub
|
||||
display_name = claims.get("displayName") or claims.get("name") or sub
|
||||
email = claims.get("email") or None
|
||||
return sub, name, display_name, email
|
||||
|
||||
|
||||
# ── The single resolver — used by every surface ──────────────────────────────
|
||||
|
||||
|
||||
async def resolve_bearer(session: AsyncSession, raw_token: str | None) -> User | None:
|
||||
"""Resolve a bare bearer-token string to a User, or None on any failure.
|
||||
|
||||
This is the one place a token becomes an identity. It never raises — the
|
||||
caller decides how to respond (401/403 for REST, 4401 close for WS). The
|
||||
header-based REST path and the ``?token=`` query path both funnel here.
|
||||
|
||||
Dev mode (SSO disabled) ignores the token and returns the dev owner.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
return await _get_or_create_dev_owner(session)
|
||||
|
||||
if not raw_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
if raw_token.startswith(PAT_PREFIX):
|
||||
return await _validate_pat(session, raw_token)
|
||||
claims = _decode_casdoor_jwt(raw_token)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
sub, name, display_name, email = _claims_to_identity(claims)
|
||||
if not sub:
|
||||
return None
|
||||
try:
|
||||
return await _find_or_create_user(session, sub, name, display_name, email)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _token_from_header(authorization_header: str | None) -> str | None:
|
||||
"""Extract the bearer token from an Authorization header, or None."""
|
||||
if not authorization_header:
|
||||
return None
|
||||
parts = authorization_header.split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return None
|
||||
token = parts[1].strip()
|
||||
return token or None
|
||||
|
||||
|
||||
async def resolve_from_header_or_query(
|
||||
session: AsyncSession,
|
||||
authorization_header: str | None,
|
||||
query_token: str | None,
|
||||
) -> User | None:
|
||||
"""Resolve a User from an Authorization header, falling back to ``?token=``.
|
||||
|
||||
The query fallback exists only for the two browser consumers that can't
|
||||
set headers — the WebSocket connect and ``<audio>`` recording downloads —
|
||||
matching Hold Slayer's long-standing narrow ``?token=`` convention. The
|
||||
header wins when both are present.
|
||||
"""
|
||||
raw = _token_from_header(authorization_header) or query_token
|
||||
return await resolve_bearer(session, raw)
|
||||
|
||||
|
||||
# ── Ownership ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def is_owner(user: User) -> bool:
|
||||
"""Whether the user owns this gateway.
|
||||
|
||||
Dev mode: the dev-owner sub is always the owner. SSO mode: the owner is
|
||||
the user whose Casdoor username (``user.name``) matches ``OWNER_NAME``.
|
||||
"""
|
||||
if not get_settings().casdoor.enabled:
|
||||
return user.casdoor_sub == _DEV_OWNER_SUB
|
||||
owner_name = get_settings().owner_name
|
||||
return bool(owner_name and user.name == owner_name)
|
||||
|
||||
|
||||
# ── FastAPI dependencies ─────────────────────────────────────────────────────
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
|
||||
token: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Resolve the authenticated user (owner or not) — 401 if unauthenticated.
|
||||
|
||||
Used by ``/auth/me`` so a signed-in non-owner sees ``is_owner:false``
|
||||
rather than a bare 401. Owner-gating is a separate step.
|
||||
"""
|
||||
header = f"Bearer {credentials.credentials}" if credentials else None
|
||||
user = await resolve_from_header_or_query(session, header, token)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_owner(user: Annotated[User, Depends(get_current_user)]) -> User:
|
||||
"""The one gate for protected surfaces — 401 if unauthenticated, 403 if not owner."""
|
||||
if not is_owner(user):
|
||||
raise HTTPException(status_code=403, detail="Owner access required")
|
||||
return user
|
||||
|
||||
|
||||
OwnerUser = Annotated[User, Depends(get_current_owner)]
|
||||
42
config.py
42
config.py
@@ -89,6 +89,25 @@ class TTSSettings(BaseSettings):
|
||||
sample_rate: int = 16000
|
||||
|
||||
|
||||
class CasdoorSettings(BaseSettings):
|
||||
"""Casdoor SSO (OIDC) configuration.
|
||||
|
||||
When `enabled` is true the browser authenticates via Casdoor and every
|
||||
surface is gated to the owner; the SDK is used only for the OAuth2 code
|
||||
exchange in the /auth/callback route (JWTs are validated against the
|
||||
endpoint's JWKS). When false, the app runs in dev-owner mode (loopback only).
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="CASDOOR_", env_file=".env", extra="ignore")
|
||||
|
||||
enabled: bool = False
|
||||
endpoint: str = "https://id.ouranos.helu.ca"
|
||||
client_id: str = ""
|
||||
client_secret: SecretStr = SecretStr("")
|
||||
org_name: str = "heluca"
|
||||
app_name: str = ""
|
||||
|
||||
|
||||
class ReceptionistSettings(BaseSettings):
|
||||
"""AI Receptionist behavior settings."""
|
||||
|
||||
@@ -126,9 +145,19 @@ class Settings(BaseSettings):
|
||||
debug: bool = False
|
||||
log_level: str = "info"
|
||||
|
||||
# Auth — one static bearer token shared by REST, WebSocket, and MCP.
|
||||
# Empty disables auth, which is only permitted on loopback binds.
|
||||
api_token: SecretStr = SecretStr("")
|
||||
# Log rendering: "text" (human-readable, for a terminal) or "json" (one
|
||||
# object per line, for Loki). Text is the default so local dev is readable;
|
||||
# the container sets LOG_FORMAT=json. See core/logging_config.py.
|
||||
log_format: str = "text"
|
||||
|
||||
# Auth — Casdoor SSO for the browser + owner-minted PATs for MCP/CLI,
|
||||
# gated to a single owner. `owner_name` is the Casdoor username that owns
|
||||
# this gateway (everyone else gets 403). `public_base_url` seeds the OAuth
|
||||
# discovery URLs; blank derives them from request headers. Both cross-cut
|
||||
# every surface, so they live on the root model (like DATABASE_URL); the
|
||||
# Casdoor connection knobs live under the CASDOOR_ prefix.
|
||||
owner_name: str = ""
|
||||
public_base_url: str = ""
|
||||
|
||||
# Outbound-call safety cap (REST + MCP make_call)
|
||||
max_concurrent_calls: int = 4
|
||||
@@ -138,6 +167,12 @@ class Settings(BaseSettings):
|
||||
# silently degrading to a gateway that can't place real calls.
|
||||
use_mock_sip: bool = False
|
||||
|
||||
# SIP stack: "sippy" (signalling only — the classifier gets no audio) or
|
||||
# "pjsua2" (call control + media, the only path where audio reaches the
|
||||
# classifier). Opt-in while the PJSUA2 engine is proven against the lab;
|
||||
# see docs/architecture.md → "Media plane: why PJSUA2 places the call".
|
||||
sip_engine: str = "sippy"
|
||||
|
||||
# Notifications
|
||||
notify_sms_number: str = ""
|
||||
|
||||
@@ -150,6 +185,7 @@ class Settings(BaseSettings):
|
||||
hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings)
|
||||
tts: TTSSettings = Field(default_factory=TTSSettings)
|
||||
receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings)
|
||||
casdoor: CasdoorSettings = Field(default_factory=CasdoorSettings)
|
||||
|
||||
|
||||
# Singleton
|
||||
|
||||
@@ -55,6 +55,26 @@ def build_sip_engine(
|
||||
"for development without a trunk."
|
||||
)
|
||||
|
||||
if settings.sip_engine.lower() == "pjsua2":
|
||||
from core.pjsua_engine import PJSUAEngine
|
||||
|
||||
logger.info("📞 SIP engine: PJSUA2 (call control + media)")
|
||||
return PJSUAEngine(
|
||||
sip_address=gw_sip.host,
|
||||
sip_port=gw_sip.port,
|
||||
trunk_host=trunk.host,
|
||||
trunk_port=trunk.port,
|
||||
trunk_username=trunk.username,
|
||||
trunk_password=trunk.password.get_secret_value(),
|
||||
trunk_transport=trunk.transport,
|
||||
domain=gw_sip.domain,
|
||||
did=trunk.did,
|
||||
media_pipeline=media_pipeline,
|
||||
on_leg_state_change=on_leg_state_change,
|
||||
on_device_registered=on_device_registered,
|
||||
on_incoming_call=on_incoming_call,
|
||||
)
|
||||
|
||||
return SippyEngine(
|
||||
sip_address=gw_sip.host,
|
||||
sip_port=gw_sip.port,
|
||||
|
||||
175
core/logging_config.py
Normal file
175
core/logging_config.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Logging configuration — human-readable text or structured JSON.
|
||||
|
||||
Hold Slayer's logs are shipped to Loki by the host's Alloy agent, which reads
|
||||
the container's stdout. Text logs arrive there as an opaque blob: filtering on
|
||||
a status code or a call ID means regex over a formatted string. JSON lines
|
||||
arrive as queryable fields.
|
||||
|
||||
Two things about this are less obvious than they look, and both are the reason
|
||||
this module exists instead of a `format=` argument on `basicConfig`:
|
||||
|
||||
1. **Uvicorn brings its own handlers.** `uvicorn.config.LOGGING_CONFIG` attaches
|
||||
a `StreamHandler` to `uvicorn` and `uvicorn.access` with `propagate: False`,
|
||||
so those records never reach the root logger's formatter. Configuring only
|
||||
the root would leave the access log — the highest-volume, most useful stream
|
||||
— as plain colourised text next to our JSON. `configure_logging` reaches into
|
||||
those two loggers explicitly.
|
||||
|
||||
2. **The access record's payload is in `record.args`, not the message.** Uvicorn
|
||||
logs access lines as a 5-tuple `(client_addr, method, full_path, http_version,
|
||||
status_code)` and lets its `AccessFormatter` interpolate them. Formatting the
|
||||
message would throw that structure away and force Loki to parse it back out,
|
||||
so `JSONFormatter` unpacks the tuple into real fields.
|
||||
|
||||
Text mode stays the default: it is what a developer wants on a terminal, and a
|
||||
JSON-only logger makes local debugging worse. Production opts in via
|
||||
`LOG_FORMAT=json`.
|
||||
"""
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
# LogRecord attributes that are either already represented in our output or are
|
||||
# formatting machinery. Anything on a record that is *not* here is treated as a
|
||||
# caller-supplied `extra=` field and promoted into the JSON object.
|
||||
_RESERVED = frozenset(
|
||||
{
|
||||
"args",
|
||||
"asctime",
|
||||
# Uvicorn passes an ANSI-colourised duplicate of the message as
|
||||
# `extra={"color_message": ...}` for its own formatter to prefer. Left
|
||||
# unfiltered, generic extra-promotion copies escape codes into every
|
||||
# startup line — the same unreadable-in-Grafana problem as the lab's
|
||||
# Asterisk logs.
|
||||
"color_message",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"module",
|
||||
"msecs",
|
||||
"message",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"taskName",
|
||||
"thread",
|
||||
"threadName",
|
||||
}
|
||||
)
|
||||
|
||||
# Uvicorn's own access-log tuple, in order.
|
||||
_ACCESS_FIELDS = ("client_addr", "method", "path", "http_version", "status_code")
|
||||
|
||||
TEXT_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""Render a LogRecord as a single-line JSON object."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
# RFC 3339 in UTC. `logging`'s default asctime is local-time and
|
||||
# date-less, which makes correlating with Loki's own timestamps
|
||||
# unnecessarily hard.
|
||||
"ts": _dt.datetime.fromtimestamp(record.created, tz=_dt.UTC).isoformat(
|
||||
timespec="milliseconds"
|
||||
),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
}
|
||||
|
||||
if record.name == "uvicorn.access" and isinstance(record.args, tuple):
|
||||
payload.update(_access_fields(record))
|
||||
else:
|
||||
payload["msg"] = record.getMessage()
|
||||
|
||||
# Thread name matters here in a way it doesn't in a single-context app:
|
||||
# this process runs the asyncio loop, the Sippy ED thread, and PJSUA2
|
||||
# worker threads, and "which context logged this" is usually the first
|
||||
# question when debugging a call.
|
||||
if record.threadName and record.threadName != "MainThread":
|
||||
payload["thread"] = record.threadName
|
||||
|
||||
if record.exc_info:
|
||||
payload["exc"] = self.formatException(record.exc_info)
|
||||
if record.stack_info:
|
||||
payload["stack"] = self.formatStack(record.stack_info)
|
||||
|
||||
for key, value in record.__dict__.items():
|
||||
if key not in _RESERVED and not key.startswith("_"):
|
||||
payload[key] = _safe(value)
|
||||
|
||||
return json.dumps(payload, default=str, separators=(",", ":"))
|
||||
|
||||
|
||||
def _access_fields(record: logging.LogRecord) -> dict[str, Any]:
|
||||
"""Unpack uvicorn's access-log arg tuple into named fields.
|
||||
|
||||
Falls back to the interpolated message if uvicorn ever changes the tuple's
|
||||
shape — a log line with a slightly wrong shape beats an exception inside the
|
||||
logging path taking out the request.
|
||||
"""
|
||||
args = record.args
|
||||
if not isinstance(args, tuple) or len(args) != len(_ACCESS_FIELDS):
|
||||
return {"msg": record.getMessage()}
|
||||
|
||||
fields: dict[str, Any] = dict(zip(_ACCESS_FIELDS, args))
|
||||
try:
|
||||
fields["status_code"] = int(fields["status_code"])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return fields
|
||||
|
||||
|
||||
def _safe(value: Any) -> Any:
|
||||
"""Keep JSON-native types; stringify everything else."""
|
||||
if isinstance(value, (str, int, float, bool, type(None))):
|
||||
return value
|
||||
return str(value)
|
||||
|
||||
|
||||
def configure_logging(log_format: str, log_level: str) -> None:
|
||||
"""Install the root and uvicorn log handlers.
|
||||
|
||||
Idempotent: existing root handlers are removed first, so calling this after
|
||||
uvicorn has configured itself replaces its formatting rather than adding a
|
||||
second stream (which is how you get every line twice).
|
||||
"""
|
||||
level = getattr(logging, log_level.upper(), logging.INFO)
|
||||
use_json = log_format.lower() == "json"
|
||||
|
||||
formatter: logging.Formatter = (
|
||||
JSONFormatter() if use_json else logging.Formatter(TEXT_FORMAT, datefmt="%H:%M:%S")
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(stream=sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root = logging.getLogger()
|
||||
for existing in root.handlers[:]:
|
||||
root.removeHandler(existing)
|
||||
root.addHandler(handler)
|
||||
root.setLevel(level)
|
||||
|
||||
# Uvicorn sets `propagate = False` on these and attaches its own colourised
|
||||
# handlers, so they must be redirected explicitly or they bypass everything
|
||||
# above. Clearing the handlers and re-enabling propagation routes them
|
||||
# through the root handler like any other logger.
|
||||
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
uv = logging.getLogger(name)
|
||||
uv.handlers.clear()
|
||||
uv.propagate = True
|
||||
uv.setLevel(level)
|
||||
@@ -20,6 +20,7 @@ PJSUA2 runs in its own thread with a dedicated Endpoint.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -98,6 +99,72 @@ class AudioTap:
|
||||
self._active = False
|
||||
|
||||
|
||||
def make_capture_port(stream_id: str, sample_rate: int, channels: int, frame_ms: int):
|
||||
"""Build a PJSUA2 media port that forks conference audio into taps.
|
||||
|
||||
Defined as a factory rather than a module-level class because
|
||||
``pj.AudioMediaPort`` can only be subclassed once ``pjsua2`` imports —
|
||||
and the whole pipeline degrades to stub mode when it doesn't.
|
||||
|
||||
The returned port is a *sink*: the conference bridge transmits into it,
|
||||
and every frame is copied to each registered tap. Returns ``None`` when
|
||||
pjsua2 is unavailable.
|
||||
"""
|
||||
try:
|
||||
import pjsua2 as pj
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
class _CapturePort(pj.AudioMediaPort):
|
||||
"""Receives conference-bridge frames and fans them out to taps.
|
||||
|
||||
``onFrameReceived`` is called on a **PJSUA2 worker thread** — a third
|
||||
execution context alongside the asyncio loop and the Sippy ED thread.
|
||||
It must touch nothing but ``AudioTap.feed``, which is explicitly
|
||||
thread-safe (it hops to the owning loop via ``call_soon_threadsafe``).
|
||||
Reaching into pipeline state, the event bus, or a Sippy object from
|
||||
here would be a data race.
|
||||
"""
|
||||
|
||||
def __init__(self, stream_id: str):
|
||||
super().__init__()
|
||||
self.stream_id = stream_id
|
||||
self.taps: list[AudioTap] = []
|
||||
self._logged_error = False
|
||||
|
||||
def onFrameReceived(self, frame): # noqa: N802 — PJSUA2 C++ callback name
|
||||
try:
|
||||
if not self.taps or frame.size <= 0:
|
||||
return
|
||||
# frame.buf is a SWIG ByteVector of signed chars; the tap
|
||||
# contract is raw little-endian 16-bit PCM.
|
||||
pcm = bytes(bytearray(b & 0xFF for b in frame.buf))
|
||||
for tap in self.taps:
|
||||
tap.feed(pcm)
|
||||
except Exception as e:
|
||||
# An exception escaping into PJSUA2's C++ callback would tear
|
||||
# down the worker thread and silently kill media for every
|
||||
# call. Log once per port rather than on every 20ms frame.
|
||||
if not self._logged_error:
|
||||
self._logged_error = True
|
||||
logger.error(
|
||||
f" Audio capture failed for {self.stream_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
fmt = pj.MediaFormatAudio()
|
||||
fmt.init(
|
||||
pj.PJMEDIA_FORMAT_L16,
|
||||
sample_rate,
|
||||
channels,
|
||||
frame_ms * 1000, # frameTimeUsec
|
||||
16, # bitsPerSample
|
||||
)
|
||||
port = _CapturePort(stream_id)
|
||||
port.createPort(f"tap-{stream_id}", fmt)
|
||||
return port
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Stream Entry — tracks a single media stream in the pipeline
|
||||
# ================================================================
|
||||
@@ -112,6 +179,8 @@ class MediaStream:
|
||||
self.codec = codec
|
||||
self.conf_port: Optional[int] = None # PJSUA2 conference bridge port ID
|
||||
self.transport = None # PJSUA2 SipTransport
|
||||
self.media = None # PJSUA2 AudioMedia for this stream
|
||||
self.capture_port = None # Shared _CapturePort feeding this stream's taps
|
||||
self.rtp_port: Optional[int] = None # Local RTP listen port
|
||||
self.taps: list[AudioTap] = []
|
||||
self.recorder = None # PJSUA2 AudioMediaRecorder
|
||||
@@ -143,10 +212,11 @@ class MediaPipeline:
|
||||
pipeline = MediaPipeline()
|
||||
await pipeline.start()
|
||||
|
||||
# Add a stream for a call leg
|
||||
port = pipeline.add_remote_stream("leg_1", "10.0.0.1", 20000, "PCMU")
|
||||
# Media arrives from the SIP engine's onCallMediaState callback
|
||||
# (PJSUA2 only surfaces RTP media for a call it owns):
|
||||
# pipeline.attach_call_media("leg_1", call.getAudioMedia(i))
|
||||
|
||||
# Tap audio for analysis
|
||||
# Tap audio for analysis — safe before or after media comes up
|
||||
tap = pipeline.create_tap("leg_1")
|
||||
async for frame in tap.stream():
|
||||
classify(frame)
|
||||
@@ -173,6 +243,7 @@ class MediaPipeline:
|
||||
self._next_rtp_port = rtp_start_port
|
||||
self._sample_rate = sample_rate
|
||||
self._channels = channels
|
||||
self._frame_ms = 20 # Must match medConfig.audioFramePtime below
|
||||
self._null_audio = null_audio # Use null audio device (no sound card needed)
|
||||
|
||||
# State
|
||||
@@ -255,11 +326,17 @@ class MediaPipeline:
|
||||
tap.close()
|
||||
self._taps.clear()
|
||||
|
||||
# Remove all streams
|
||||
# Remove all streams (this releases their capture ports)
|
||||
for stream_id in list(self._streams.keys()):
|
||||
self.remove_stream(stream_id)
|
||||
|
||||
# Destroy PJSUA2 endpoint
|
||||
# Destroy PJSUA2 endpoint. Every media port must be collected first:
|
||||
# a port finalised after libDestroy() runs pjmedia_conf_remove_port
|
||||
# against a freed conference bridge and aborts the process. Dropping
|
||||
# the last Python reference is not enough on its own — force the
|
||||
# collection here rather than leaving it to interpreter exit.
|
||||
gc.collect()
|
||||
|
||||
if self._endpoint:
|
||||
try:
|
||||
self._endpoint.libDestroy()
|
||||
@@ -270,6 +347,15 @@ class MediaPipeline:
|
||||
self._ready = False
|
||||
logger.info("🎵 PJSUA2 media pipeline stopped")
|
||||
|
||||
@property
|
||||
def endpoint(self):
|
||||
"""The PJSUA2 Endpoint, or None in stub mode.
|
||||
|
||||
PJSUA2 permits exactly one Endpoint per process, so the pipeline
|
||||
creates it and the SIP engine borrows it rather than making a second.
|
||||
"""
|
||||
return self._endpoint
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
return self._ready
|
||||
@@ -291,50 +377,51 @@ class MediaPipeline:
|
||||
# Stream Management
|
||||
# ================================================================
|
||||
|
||||
def add_remote_stream(
|
||||
self, stream_id: str, remote_host: str, remote_port: int, codec: str = "PCMU"
|
||||
) -> Optional[int]:
|
||||
def attach_call_media(self, stream_id: str, audio_media) -> Optional[int]:
|
||||
"""Register a call's live ``AudioMedia`` with the pipeline.
|
||||
|
||||
Called from ``onCallMediaState`` on a PJSUA2 worker thread, which is
|
||||
the only place PJSUA2 surfaces RTP-backed media. Any tap created
|
||||
before this point is attached now; taps created later find the media
|
||||
already present.
|
||||
|
||||
There is deliberately no ``add_remote_stream(host, port)`` counterpart:
|
||||
PJSUA2 has no standalone RTP media object, so media can only arrive
|
||||
from a call PJSUA2 owns. See ``docs/architecture.md``.
|
||||
"""
|
||||
Add a remote RTP stream to the conference bridge.
|
||||
stream = self._streams.get(stream_id)
|
||||
if stream is None:
|
||||
stream = MediaStream(stream_id, "", 0)
|
||||
self._streams[stream_id] = stream
|
||||
|
||||
Creates a PJSUA2 transport and media port for the remote
|
||||
party's RTP stream, connecting it to the conference bridge.
|
||||
|
||||
Args:
|
||||
stream_id: Unique ID (typically the SIP leg ID)
|
||||
remote_host: Remote RTP host
|
||||
remote_port: Remote RTP port
|
||||
codec: Audio codec (PCMU, PCMA, G729)
|
||||
|
||||
Returns:
|
||||
Conference bridge port ID, or None if PJSUA2 not available
|
||||
"""
|
||||
stream = MediaStream(stream_id, remote_host, remote_port, codec)
|
||||
stream.rtp_port = self.allocate_rtp_port(stream_id)
|
||||
|
||||
if self._endpoint:
|
||||
stream.media = audio_media
|
||||
try:
|
||||
import pjsua2 as pj
|
||||
stream.conf_port = audio_media.getPortId()
|
||||
except Exception:
|
||||
stream.conf_port = None
|
||||
|
||||
# Create a media transport for this stream
|
||||
# In a full implementation, we'd create an AudioMediaPort
|
||||
# that receives RTP and feeds it into the conference bridge
|
||||
transport_cfg = pj.TransportConfig()
|
||||
transport_cfg.port = stream.rtp_port
|
||||
|
||||
# The conference bridge port will be assigned when
|
||||
# the call's media is activated via onCallMediaState
|
||||
# Wire up taps that were requested before media came up.
|
||||
pending = self._taps.get(stream_id, [])
|
||||
if pending and stream.capture_port is None:
|
||||
port = make_capture_port(
|
||||
stream_id, self._sample_rate, self._channels, self._frame_ms
|
||||
)
|
||||
if port is not None:
|
||||
try:
|
||||
audio_media.startTransmit(port)
|
||||
stream.capture_port = port
|
||||
port.taps.extend(pending)
|
||||
logger.info(
|
||||
f" 📡 Added stream {stream_id}: "
|
||||
f"local={stream.rtp_port} → remote={remote_host}:{remote_port} ({codec})"
|
||||
f" 🎤 Audio tap attached for {stream_id} "
|
||||
f"({len(pending)} waiting)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f" Failed to attach capture port for {stream_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
logger.debug(f" PJSUA2 not available, stream {stream_id} is virtual")
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to add stream {stream_id}: {e}")
|
||||
|
||||
self._streams[stream_id] = stream
|
||||
logger.info(f" 📡 Media attached for {stream_id} (conf port {stream.conf_port})")
|
||||
return stream.conf_port
|
||||
|
||||
def remove_stream(self, stream_id: str) -> None:
|
||||
@@ -350,6 +437,19 @@ class MediaPipeline:
|
||||
tap.close()
|
||||
self._taps.pop(stream_id, None)
|
||||
|
||||
# Release the capture port while the conference bridge still exists.
|
||||
# A port garbage-collected after Endpoint.libDestroy() calls
|
||||
# pjmedia_conf_remove_port against a freed bridge and aborts the
|
||||
# process on a native assertion — a hard crash, not an exception.
|
||||
if stream.capture_port is not None:
|
||||
try:
|
||||
if stream.media is not None:
|
||||
stream.media.stopTransmit(stream.capture_port)
|
||||
except Exception as e:
|
||||
logger.debug(f" stopTransmit failed for {stream_id}: {e}")
|
||||
stream.capture_port.taps.clear()
|
||||
stream.capture_port = None
|
||||
|
||||
# Stop recording
|
||||
if stream.recorder:
|
||||
try:
|
||||
@@ -426,16 +526,28 @@ class MediaPipeline:
|
||||
self._taps[stream_id] = []
|
||||
self._taps[stream_id].append(tap)
|
||||
|
||||
if self._endpoint and stream and stream.conf_port is not None:
|
||||
if self._endpoint and stream and stream.media is not None:
|
||||
try:
|
||||
import pjsua2 as pj
|
||||
# Create an AudioMediaPort that captures frames
|
||||
# and feeds them to the tap
|
||||
# In PJSUA2, we'd subclass AudioMediaPort and implement
|
||||
# onFrameReceived to call tap.feed(frame_data)
|
||||
# One capture port per stream, shared by every tap on it:
|
||||
# the bridge would otherwise mix each additional port back
|
||||
# into the conference and the call would echo.
|
||||
if stream.capture_port is None:
|
||||
port = make_capture_port(
|
||||
stream_id, self._sample_rate, self._channels, self._frame_ms
|
||||
)
|
||||
if port is not None:
|
||||
# The stream's media transmits into the capture port,
|
||||
# not the reverse — the port is a sink.
|
||||
stream.media.startTransmit(port)
|
||||
stream.capture_port = port
|
||||
logger.info(f" 🎤 Audio tap created for {stream_id} (PJSUA2)")
|
||||
|
||||
if stream.capture_port is not None:
|
||||
stream.capture_port.taps.append(tap)
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to create PJSUA2 tap for {stream_id}: {e}")
|
||||
logger.error(
|
||||
f" Failed to create PJSUA2 tap for {stream_id}: {e}", exc_info=True
|
||||
)
|
||||
else:
|
||||
logger.info(f" 🎤 Audio tap created for {stream_id} (virtual)")
|
||||
|
||||
|
||||
488
core/pjsua_engine.py
Normal file
488
core/pjsua_engine.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
PJSUA2 SIP engine — call control *and* media in one library.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The gateway originally signalled with Sippy and expected PJSUA2 to carry
|
||||
media. That cannot work: **PJSUA2 exposes no standalone RTP media object**.
|
||||
Every ``AudioMedia`` subclass in the Python bindings is a file player,
|
||||
recorder, tone generator or capture port, and RTP is reachable only through
|
||||
``pj.Call.getAudioMedia()`` — on a dialog PJSUA2 itself owns. A design where
|
||||
another stack owns the dialog can never obtain media from PJSUA2, so audio
|
||||
never reached the classifier.
|
||||
|
||||
Owning the dialog is the price of owning the media, so this engine places the
|
||||
call. See ``docs/architecture.md`` → "Media plane: why PJSUA2 places the call".
|
||||
|
||||
Safety
|
||||
------
|
||||
This engine is *only* reached through ``gateway.make_call``, which refuses
|
||||
emergency numbers and enforces the concurrency cap **before** any SIP action.
|
||||
Nothing here may be given a second dial path that bypasses those checks.
|
||||
|
||||
Threading
|
||||
---------
|
||||
Three execution contexts, as elsewhere in the codebase:
|
||||
|
||||
* the **asyncio loop** owns legs, the event bus, and the call manager;
|
||||
* **PJSUA2 worker threads** run every ``on*`` callback below;
|
||||
* (the Sippy ED thread is not involved — this engine replaces it.)
|
||||
|
||||
PJSUA2 callbacks cross to the loop through exactly one funnel,
|
||||
``_post_from_pj`` → ``run_coroutine_threadsafe``. A callback must never touch
|
||||
loop-owned state directly. Any thread PJSUA2 did not create must call
|
||||
``libRegisterThread`` before touching a PJSUA2 object, which
|
||||
``_ensure_registered`` handles.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
from core.sip_engine import SIPEngine
|
||||
from models.device import Device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PJSUAEngine(SIPEngine):
|
||||
"""SIP engine backed by PJSUA2 for both signalling and media."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sip_address: str = "0.0.0.0",
|
||||
sip_port: int = 5060,
|
||||
trunk_host: str = "",
|
||||
trunk_port: int = 5060,
|
||||
trunk_username: str = "",
|
||||
trunk_password: str = "",
|
||||
trunk_transport: str = "udp",
|
||||
domain: str = "gateway.local",
|
||||
did: str = "",
|
||||
media_pipeline=None,
|
||||
on_leg_state_change: Callable | None = None,
|
||||
on_device_registered: Callable | None = None,
|
||||
on_incoming_call: Callable | None = None,
|
||||
):
|
||||
self._sip_address = sip_address
|
||||
self._sip_port = sip_port
|
||||
self._trunk_host = trunk_host
|
||||
self._trunk_port = trunk_port
|
||||
self._trunk_username = trunk_username
|
||||
self._trunk_password = trunk_password
|
||||
self._trunk_transport = trunk_transport
|
||||
self._domain = domain
|
||||
self._did = did
|
||||
|
||||
# The media pipeline owns the PJSUA2 Endpoint; this engine borrows it
|
||||
# rather than creating a second one (PJSUA2 permits only one).
|
||||
self.media_pipeline = media_pipeline
|
||||
|
||||
self._on_leg_state_change = on_leg_state_change
|
||||
self._on_device_registered = on_device_registered
|
||||
self._on_incoming_call = on_incoming_call
|
||||
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._ready = False
|
||||
self._account = None
|
||||
self._trunk_registered = False
|
||||
self._trunk_reason = "not started"
|
||||
|
||||
# PJSUA2-thread-owned: maps leg_id → pj.Call. Only touched from a
|
||||
# PJSUA2 callback or a method that has registered itself first.
|
||||
self._calls: dict[str, object] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ================================================================
|
||||
# Thread boundary
|
||||
# ================================================================
|
||||
|
||||
def _post_from_pj(self, coro) -> None:
|
||||
"""Schedule loop work from a PJSUA2 worker thread. The one funnel."""
|
||||
if self._loop is None:
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
|
||||
def _ensure_registered(self) -> None:
|
||||
"""Register the calling thread with PJSUA2 if it isn't already.
|
||||
|
||||
PJSUA2 aborts when a thread it does not know touches its objects.
|
||||
Calls made from the asyncio loop (hangup, DTMF) hit this.
|
||||
"""
|
||||
try:
|
||||
import pjsua2 as pj
|
||||
|
||||
ep = pj.Endpoint.instance()
|
||||
if not ep.libIsThreadRegistered():
|
||||
ep.libRegisterThread(threading.current_thread().name)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.debug(f" thread registration skipped: {e}")
|
||||
|
||||
async def _emit_leg_state(self, leg_id: str, state: str) -> None:
|
||||
"""Deliver a leg-state change on the loop."""
|
||||
if self._on_leg_state_change is None:
|
||||
return
|
||||
result = self._on_leg_state_change(leg_id, state)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
|
||||
# ================================================================
|
||||
# Lifecycle
|
||||
# ================================================================
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Create the SIP transport and register with the trunk."""
|
||||
self._loop = asyncio.get_running_loop()
|
||||
logger.info("🔌 Starting PJSUA2 SIP engine...")
|
||||
|
||||
if self.media_pipeline is None or not self.media_pipeline.endpoint:
|
||||
raise RuntimeError(
|
||||
"PJSUAEngine requires a started MediaPipeline — PJSUA2 allows "
|
||||
"only one Endpoint, so the pipeline owns it and the engine "
|
||||
"borrows it."
|
||||
)
|
||||
|
||||
import pjsua2 as pj
|
||||
|
||||
ep = self.media_pipeline.endpoint
|
||||
|
||||
transport_cfg = pj.TransportConfig()
|
||||
transport_cfg.port = self._sip_port
|
||||
if self._sip_address and self._sip_address != "0.0.0.0":
|
||||
transport_cfg.boundAddress = self._sip_address
|
||||
|
||||
tp_type = (
|
||||
pj.PJSIP_TRANSPORT_TCP
|
||||
if self._trunk_transport.lower() == "tcp"
|
||||
else pj.PJSIP_TRANSPORT_UDP
|
||||
)
|
||||
ep.transportCreate(tp_type, transport_cfg)
|
||||
|
||||
self._create_account(ep)
|
||||
|
||||
self._ready = True
|
||||
logger.info(f"🔌 PJSUA2 SIP engine ready on {self._sip_address}:{self._sip_port}")
|
||||
|
||||
def _create_account(self, ep) -> None:
|
||||
"""Build the account — registered to the trunk, or local-only."""
|
||||
import pjsua2 as pj
|
||||
|
||||
engine = self
|
||||
|
||||
class _Account(pj.Account):
|
||||
def onRegState(self, prm): # noqa: N802 — PJSUA2 callback name
|
||||
try:
|
||||
info = self.getInfo()
|
||||
engine._trunk_registered = bool(info.regIsActive)
|
||||
engine._trunk_reason = f"{prm.code} {prm.reason}".strip()
|
||||
if info.regIsActive:
|
||||
logger.info(" ✅ Trunk registration accepted")
|
||||
else:
|
||||
# A rejected REGISTER must not read as "registered":
|
||||
# /health treats a registered trunk as a condition of
|
||||
# being healthy.
|
||||
logger.error(
|
||||
f" ❌ Trunk registration failed: {engine._trunk_reason}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f" onRegState error: {e}", exc_info=True)
|
||||
|
||||
def onIncomingCall(self, prm): # noqa: N802 — PJSUA2 callback name
|
||||
try:
|
||||
engine._handle_incoming(self, prm.callId)
|
||||
except Exception as e:
|
||||
logger.error(f" onIncomingCall error: {e}", exc_info=True)
|
||||
|
||||
acc_cfg = pj.AccountConfig()
|
||||
|
||||
if self._trunk_host:
|
||||
acc_cfg.idUri = f"sip:{self._trunk_username}@{self._trunk_host}"
|
||||
acc_cfg.regConfig.registrarUri = f"sip:{self._trunk_host}:{self._trunk_port}"
|
||||
cred = pj.AuthCredInfo(
|
||||
"digest", "*", self._trunk_username, 0, self._trunk_password
|
||||
)
|
||||
acc_cfg.sipConfig.authCreds.append(cred)
|
||||
else:
|
||||
# No trunk configured: a local-only account still lets devices
|
||||
# register and inbound calls arrive.
|
||||
acc_cfg.idUri = f"sip:gateway@{self._domain}"
|
||||
self._trunk_reason = "No SIP trunk configured"
|
||||
|
||||
self._account = _Account()
|
||||
self._account.create(acc_cfg)
|
||||
|
||||
if self._trunk_host:
|
||||
logger.info(f" Registering with trunk: {self._trunk_host}:{self._trunk_port}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Hang up everything and drop the account."""
|
||||
logger.info("🔌 Stopping PJSUA2 SIP engine...")
|
||||
self._ready = False
|
||||
|
||||
self._ensure_registered()
|
||||
had_calls = bool(self._calls)
|
||||
for leg_id in list(self._calls.keys()):
|
||||
try:
|
||||
await self.hangup(leg_id)
|
||||
except Exception as e:
|
||||
logger.debug(f" hangup during shutdown failed for {leg_id}: {e}")
|
||||
|
||||
# hangup() only queues the BYE. Give PJSUA2 a moment to send it and
|
||||
# tear the media down, or the account is deleted with a call still
|
||||
# active ("deleting account 0 while call 0 is still active") and the
|
||||
# far end is left waiting on a dialog nobody closed.
|
||||
if had_calls:
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Drop every PJSUA2 object before the pipeline destroys the endpoint.
|
||||
# A Call or Account finalised after libDestroy() aborts the process on
|
||||
# a native assertion, exactly as a stray media port does — and a Call
|
||||
# still alive keeps delivering callbacks into a half-torn-down
|
||||
# interpreter. Dropping the last reference is not enough on its own,
|
||||
# so force the collection here.
|
||||
with self._lock:
|
||||
self._calls.clear()
|
||||
self._account = None
|
||||
gc.collect()
|
||||
logger.info("🔌 PJSUA2 SIP engine stopped")
|
||||
|
||||
async def is_ready(self) -> bool:
|
||||
return self._ready
|
||||
|
||||
# ================================================================
|
||||
# Calls
|
||||
# ================================================================
|
||||
|
||||
def _make_call_class(self):
|
||||
"""Build the pj.Call subclass bound to this engine."""
|
||||
import pjsua2 as pj
|
||||
|
||||
engine = self
|
||||
|
||||
class _Call(pj.Call):
|
||||
def __init__(self, acc, leg_id: str, call_id=pj.PJSUA_INVALID_ID):
|
||||
super().__init__(acc, call_id)
|
||||
self.leg_id = leg_id
|
||||
|
||||
def onCallState(self, prm): # noqa: N802 — PJSUA2 callback name
|
||||
# PJSUA2 keeps delivering callbacks while the interpreter is
|
||||
# tearing down, when module globals may already be cleared —
|
||||
# hence the local alias and the bare except. A raise here
|
||||
# escapes into C++ and takes the worker thread with it.
|
||||
state_map = _STATE_MAP
|
||||
try:
|
||||
info = self.getInfo()
|
||||
state = state_map.get(info.state)
|
||||
if state is None:
|
||||
return
|
||||
if state == "terminated":
|
||||
engine._forget_call(self.leg_id)
|
||||
engine._post_from_pj(engine._emit_leg_state(self.leg_id, state))
|
||||
except Exception:
|
||||
try:
|
||||
logger.error(" onCallState error", exc_info=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def onCallMediaState(self, prm): # noqa: N802 — PJSUA2 callback name
|
||||
"""Media is up — hand the audio to the pipeline.
|
||||
|
||||
This is the callback the whole refactor exists for: it is the
|
||||
only place PJSUA2 surfaces an RTP-backed AudioMedia.
|
||||
"""
|
||||
try:
|
||||
info = self.getInfo()
|
||||
for i, mi in enumerate(info.media):
|
||||
if (
|
||||
mi.type == pj.PJMEDIA_TYPE_AUDIO
|
||||
and mi.status == pj.PJSUA_CALL_MEDIA_ACTIVE
|
||||
):
|
||||
engine._attach_media(self.leg_id, self.getAudioMedia(i))
|
||||
break
|
||||
except Exception:
|
||||
try:
|
||||
logger.error(" onCallMediaState error", exc_info=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _Call
|
||||
|
||||
def _attach_media(self, leg_id: str, audio_media) -> None:
|
||||
"""Register a live AudioMedia with the pipeline (PJSUA2 thread)."""
|
||||
if self.media_pipeline is None:
|
||||
return
|
||||
try:
|
||||
self.media_pipeline.attach_call_media(leg_id, audio_media)
|
||||
logger.info(f" 🎵 Media active for {leg_id}")
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to attach media for {leg_id}: {e}", exc_info=True)
|
||||
|
||||
def _forget_call(self, leg_id: str) -> None:
|
||||
with self._lock:
|
||||
self._calls.pop(leg_id, None)
|
||||
if self.media_pipeline is not None:
|
||||
try:
|
||||
self.media_pipeline.remove_stream(leg_id)
|
||||
except Exception as e:
|
||||
logger.debug(f" stream cleanup failed for {leg_id}: {e}")
|
||||
|
||||
async def make_call(self, number: str, caller_id: str | None = None) -> str:
|
||||
"""Place an outbound call. Reached only via gateway.make_call."""
|
||||
if not self._ready:
|
||||
raise RuntimeError("SIP engine not ready")
|
||||
|
||||
import pjsua2 as pj
|
||||
|
||||
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
||||
target = (
|
||||
f"sip:{number}@{self._trunk_host}:{self._trunk_port}"
|
||||
if self._trunk_host
|
||||
else f"sip:{number}@{self._domain}"
|
||||
)
|
||||
logger.info(f"📞 Placing call to {target} (leg: {leg_id})")
|
||||
|
||||
self._ensure_registered()
|
||||
call_cls = self._make_call_class()
|
||||
call = call_cls(self._account, leg_id)
|
||||
|
||||
prm = pj.CallOpParam(True)
|
||||
call.makeCall(target, prm)
|
||||
|
||||
with self._lock:
|
||||
self._calls[leg_id] = call
|
||||
return leg_id
|
||||
|
||||
async def hangup(self, call_leg_id: str) -> None:
|
||||
import pjsua2 as pj
|
||||
|
||||
with self._lock:
|
||||
call = self._calls.get(call_leg_id)
|
||||
if call is None:
|
||||
return
|
||||
self._ensure_registered()
|
||||
try:
|
||||
call.hangup(pj.CallOpParam(True))
|
||||
except Exception as e:
|
||||
logger.debug(f" hangup failed for {call_leg_id}: {e}")
|
||||
self._forget_call(call_leg_id)
|
||||
|
||||
async def send_dtmf(self, call_leg_id: str, digits: str) -> None:
|
||||
"""Send DTMF as RFC 2833 — the in-band path a real IVR expects."""
|
||||
with self._lock:
|
||||
call = self._calls.get(call_leg_id)
|
||||
if call is None:
|
||||
logger.warning(f" send_dtmf: no call for {call_leg_id}")
|
||||
return
|
||||
self._ensure_registered()
|
||||
call.dialDtmf(digits)
|
||||
logger.info(f" Sent DTMF '{digits}' on {call_leg_id}")
|
||||
|
||||
async def call_device(self, device: Device) -> str:
|
||||
"""Ring a registered device (transfer target)."""
|
||||
if not self._ready:
|
||||
raise RuntimeError("SIP engine not ready")
|
||||
|
||||
import pjsua2 as pj
|
||||
|
||||
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
||||
target = device.sip_uri or f"sip:{device.id}@{self._domain}"
|
||||
logger.info(f"📞 Ringing device {device.id} at {target} (leg: {leg_id})")
|
||||
|
||||
self._ensure_registered()
|
||||
call_cls = self._make_call_class()
|
||||
call = call_cls(self._account, leg_id)
|
||||
call.makeCall(target, pj.CallOpParam(True))
|
||||
|
||||
with self._lock:
|
||||
self._calls[leg_id] = call
|
||||
return leg_id
|
||||
|
||||
def _handle_incoming(self, account, call_id) -> None:
|
||||
"""Inbound INVITE (PJSUA2 thread) — answer and hand to the receptionist."""
|
||||
import pjsua2 as pj
|
||||
|
||||
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
||||
call_cls = self._make_call_class()
|
||||
call = call_cls(account, leg_id, call_id)
|
||||
|
||||
try:
|
||||
info = call.getInfo()
|
||||
remote = info.remoteUri
|
||||
except Exception:
|
||||
remote = "unknown"
|
||||
|
||||
with self._lock:
|
||||
self._calls[leg_id] = call
|
||||
|
||||
call.answer(pj.CallOpParam(True))
|
||||
logger.info(f"📞 Inbound call {leg_id} from {remote}")
|
||||
|
||||
if self._on_incoming_call is not None:
|
||||
result = self._on_incoming_call(leg_id, remote)
|
||||
if asyncio.iscoroutine(result):
|
||||
self._post_from_pj(result)
|
||||
|
||||
# ================================================================
|
||||
# Bridging
|
||||
# ================================================================
|
||||
|
||||
async def bridge_calls(self, leg_a: str, leg_b: str) -> str:
|
||||
"""Join two legs in the conference bridge."""
|
||||
bridge_id = f"bridge_{uuid.uuid4().hex[:8]}"
|
||||
if self.media_pipeline is not None:
|
||||
self.media_pipeline.bridge_streams(leg_a, leg_b)
|
||||
logger.info(f" 🌉 Bridged {leg_a} ↔ {leg_b} ({bridge_id})")
|
||||
return bridge_id
|
||||
|
||||
async def unbridge(self, bridge_id: str) -> None:
|
||||
logger.info(f" Unbridged {bridge_id}")
|
||||
|
||||
def get_audio_stream(self, call_leg_id: str):
|
||||
if self.media_pipeline is not None:
|
||||
return self.media_pipeline.get_audio_tap(call_leg_id)
|
||||
return None
|
||||
|
||||
# ================================================================
|
||||
# Status
|
||||
# ================================================================
|
||||
|
||||
async def get_registered_devices(self) -> list[dict]:
|
||||
return []
|
||||
|
||||
async def get_trunk_status(self) -> dict:
|
||||
return {
|
||||
"registered": self._trunk_registered,
|
||||
"host": self._trunk_host or "not configured",
|
||||
"port": self._trunk_port,
|
||||
"transport": self._trunk_transport,
|
||||
"username": self._trunk_username,
|
||||
"reason": None if self._trunk_registered else self._trunk_reason,
|
||||
}
|
||||
|
||||
|
||||
# Populated lazily: the pjsua2 constants are unavailable until import, and
|
||||
# the module must import cleanly in stub mode.
|
||||
_STATE_MAP: dict = {}
|
||||
|
||||
|
||||
def _init_state_map() -> None:
|
||||
global _STATE_MAP
|
||||
if _STATE_MAP:
|
||||
return
|
||||
try:
|
||||
import pjsua2 as pj
|
||||
except ImportError:
|
||||
return
|
||||
_STATE_MAP = {
|
||||
pj.PJSIP_INV_STATE_CALLING: "trying",
|
||||
pj.PJSIP_INV_STATE_EARLY: "ringing",
|
||||
pj.PJSIP_INV_STATE_CONNECTING: "trying",
|
||||
pj.PJSIP_INV_STATE_CONFIRMED: "connected",
|
||||
pj.PJSIP_INV_STATE_DISCONNECTED: "terminated",
|
||||
}
|
||||
|
||||
|
||||
_init_state_map()
|
||||
132
core/rate_limit.py
Normal file
132
core/rate_limit.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Rate limiting for the unauthenticated edge.
|
||||
|
||||
**Scope, and why it is narrow.** Every REST/WS/MCP surface is owner-only: an
|
||||
unauthenticated request is rejected by `resolve_bearer`/`is_owner` before any
|
||||
handler runs, and real spend control for outbound calls is
|
||||
`max_concurrent_calls` in `gateway.make_call`. Blanket per-endpoint limits would
|
||||
therefore mostly rate-limit the single legitimate operator. What is genuinely
|
||||
exposed is the handful of `/auth/*` routes that must answer before an identity
|
||||
exists — those are limited here, and nothing else.
|
||||
|
||||
**What this defends against.** Not credential guessing: PATs are
|
||||
`secrets.token_urlsafe(32)` (256 bits) compared by SHA-256 digest, so brute
|
||||
force is not a practical threat. The concern is *unauthenticated work an
|
||||
attacker controls*: `/auth/callback` makes an outbound token-exchange round-trip
|
||||
to Casdoor on every request, and `/auth/me` opens a DB session and runs a token
|
||||
lookup. Both are free to trigger and neither is cheap to serve.
|
||||
|
||||
**Fixed-window, in-process, no dependency.** One operator and a single process
|
||||
mean a shared counter store would be infrastructure without a purpose. The
|
||||
trade-off of a fixed window is a burst of up to 2× the limit across a boundary;
|
||||
that is irrelevant at these thresholds, and it costs one dict lookup with no
|
||||
background task.
|
||||
|
||||
**Client identity is the socket peer, deliberately.** `X-Forwarded-For` is
|
||||
attacker-controlled unless a trusted proxy overwrites it, and this app does not
|
||||
currently establish that trust (`_public_base_url` reads forwarded headers, but
|
||||
only to build URLs). Keying on a spoofable header would let one client present
|
||||
as thousands and make the limiter worse than useless. Behind the estate's
|
||||
reverse proxy this means the limit applies per-proxy rather than per-caller —
|
||||
correct for exhaustion, and honest about what it can enforce. If per-caller
|
||||
limits are ever needed, that needs an explicit trusted-proxy config, not a
|
||||
silent `X-Forwarded-For` read.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
# Requests allowed per window, per client, per route. Sized to be invisible to a
|
||||
# human — a dashboard load touches /auth/me once — while capping automated
|
||||
# hammering.
|
||||
DEFAULT_LIMIT = 30
|
||||
DEFAULT_WINDOW_SECONDS = 60
|
||||
|
||||
# Stop the bucket store growing without bound under a spray of source addresses.
|
||||
# Eviction is oldest-first and only runs when the cap is exceeded.
|
||||
MAX_TRACKED_CLIENTS = 10_000
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Fixed-window request counter, keyed by (route, client)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
window_seconds: int = DEFAULT_WINDOW_SECONDS,
|
||||
max_clients: int = MAX_TRACKED_CLIENTS,
|
||||
):
|
||||
self.limit = limit
|
||||
self.window_seconds = window_seconds
|
||||
self.max_clients = max_clients
|
||||
# key -> [window_start, count]. Insertion-ordered, which is what makes
|
||||
# oldest-first eviction a cheap `next(iter(...))`.
|
||||
self._buckets: dict[str, list[float]] = {}
|
||||
|
||||
def check(
|
||||
self, key: str, limit: int | None = None, now: float | None = None
|
||||
) -> tuple[bool, int]:
|
||||
"""Record a hit. Returns (allowed, retry_after_seconds).
|
||||
|
||||
`retry_after` is 0 when allowed, and the seconds remaining in the
|
||||
current window when not. `limit` overrides the instance default for
|
||||
routes that want a tighter cap.
|
||||
"""
|
||||
now = time.monotonic() if now is None else now
|
||||
effective = self.limit if limit is None else limit
|
||||
bucket = self._buckets.get(key)
|
||||
|
||||
if bucket is None or now - bucket[0] >= self.window_seconds:
|
||||
self._buckets.pop(key, None) # re-insert so ordering tracks recency
|
||||
self._buckets[key] = [now, 1]
|
||||
self._evict_if_needed()
|
||||
return True, 0
|
||||
|
||||
bucket[1] += 1
|
||||
if bucket[1] > effective:
|
||||
remaining = self.window_seconds - (now - bucket[0])
|
||||
return False, max(1, int(remaining) + 1)
|
||||
return True, 0
|
||||
|
||||
def _evict_if_needed(self) -> None:
|
||||
while len(self._buckets) > self.max_clients:
|
||||
self._buckets.pop(next(iter(self._buckets)))
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buckets.clear()
|
||||
|
||||
|
||||
def client_key(request: Request, scope: str) -> str:
|
||||
"""Identify the caller for limiting purposes.
|
||||
|
||||
Uses the socket peer, never a forwarded header — see the module docstring.
|
||||
"""
|
||||
client = request.client.host if request.client else "unknown"
|
||||
return f"{scope}:{client}"
|
||||
|
||||
|
||||
_limiter = RateLimiter()
|
||||
|
||||
|
||||
def get_limiter() -> RateLimiter:
|
||||
return _limiter
|
||||
|
||||
|
||||
def rate_limit(scope: str, limit: int | None = None):
|
||||
"""FastAPI dependency factory limiting one route.
|
||||
|
||||
Applied per-route rather than as middleware so the authenticated surfaces —
|
||||
which are already owner-gated — pay nothing.
|
||||
"""
|
||||
|
||||
async def _dependency(request: Request) -> None:
|
||||
allowed, retry_after = get_limiter().check(client_key(request, scope), limit=limit)
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many requests",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
return _dependency
|
||||
@@ -84,6 +84,48 @@ class SippyCallController:
|
||||
self.leg_id = leg_id
|
||||
self.engine = engine
|
||||
|
||||
def __call__(self, event, ua) -> None:
|
||||
"""Sippy's ``event_cb`` — invoked as ``event_cb(event, ua)``.
|
||||
|
||||
Sippy delivers call progress as CCEvent objects through this one
|
||||
entry point; it never calls the ``on_*`` methods directly. This
|
||||
dispatches to them so each SIP fact still has a named handler.
|
||||
"""
|
||||
from sippy.CCEvents import (
|
||||
CCEventConnect,
|
||||
CCEventDisconnect,
|
||||
CCEventFail,
|
||||
CCEventPreConnect,
|
||||
CCEventRing,
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(event, CCEventRing):
|
||||
self.on_ringing()
|
||||
elif isinstance(event, (CCEventConnect, CCEventPreConnect)):
|
||||
# data is (code, reason, body) — the body carries the
|
||||
# negotiated SDP that tells the media pipeline where to
|
||||
# send RTP.
|
||||
data = event.getData()
|
||||
body = data[2] if isinstance(data, tuple) and len(data) > 2 else None
|
||||
self.on_connected(str(body) if body is not None else None)
|
||||
elif isinstance(event, CCEventDisconnect):
|
||||
self.on_disconnected("remote hangup")
|
||||
elif isinstance(event, CCEventFail):
|
||||
data = event.getData()
|
||||
reason = " ".join(str(d) for d in data[:2]) if data else "call failed"
|
||||
self.on_disconnected(reason)
|
||||
# DTMF is not handled here: SIP INFO arrives as a request and is
|
||||
# picked up by _handle_incoming_info, and RFC 2833 DTMF rides in
|
||||
# the RTP stream, which is the media pipeline's business.
|
||||
except Exception as e:
|
||||
# This runs on the Sippy ED thread: an escaping exception is
|
||||
# swallowed by the dispatcher and the leg would hang silently.
|
||||
logger.error(
|
||||
f" {self.leg_id}: error handling {type(event).__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def on_trying(self):
|
||||
"""100 Trying received."""
|
||||
logger.debug(f" {self.leg_id}: 100 Trying")
|
||||
@@ -234,17 +276,22 @@ class SippyEngine(SIPEngine):
|
||||
if state == "connected":
|
||||
sdp = data.get("sdp")
|
||||
if sdp and self.media_pipeline:
|
||||
# Signalling only: PJSUA2 surfaces RTP media exclusively
|
||||
# through a call it owns, so a Sippy-owned dialog can
|
||||
# never be given media — the classifier stays deaf on this
|
||||
# engine. PJSUAEngine is the media-capable path; see
|
||||
# docs/architecture.md. Log the negotiated endpoint so the
|
||||
# SIP exchange is still debuggable.
|
||||
try:
|
||||
remote_rtp = self._parse_sdp_rtp_endpoint(sdp)
|
||||
if remote_rtp:
|
||||
leg.media_port = self.media_pipeline.add_remote_stream(
|
||||
leg.leg_id,
|
||||
remote_rtp["host"],
|
||||
remote_rtp["port"],
|
||||
remote_rtp["codec"],
|
||||
logger.info(
|
||||
f" {leg.leg_id}: remote RTP "
|
||||
f"{remote_rtp['host']}:{remote_rtp['port']} "
|
||||
f"({remote_rtp['codec']}) — no media on this engine"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to set up media for {leg.leg_id}: {e}")
|
||||
logger.error(f" Failed to parse SDP for {leg.leg_id}: {e}")
|
||||
elif state == "terminated":
|
||||
if self.media_pipeline and leg.media_port is not None:
|
||||
try:
|
||||
@@ -319,10 +366,17 @@ class SippyEngine(SIPEngine):
|
||||
SipConf.my_port = self._sip_port
|
||||
SipConf.my_uaname = "Hold Slayer Gateway"
|
||||
|
||||
# SipTransactionManager dereferences _sip_logger unconditionally on
|
||||
# every message, so it must exist before any SIP traffic. It
|
||||
# defaults to the stderr backend (SIPLOG_BEND), not the
|
||||
# /var/log/sip.log path in its signature — nothing to create.
|
||||
from sippy.SipLogger import SipLogger
|
||||
|
||||
self._sippy_global_config = {
|
||||
"_sip_address": self._sip_address,
|
||||
"_sip_port": self._sip_port,
|
||||
"_sip_tm": None, # Transaction manager set after start
|
||||
"_sip_logger": SipLogger("hold-slayer"),
|
||||
}
|
||||
|
||||
# Start Sippy's SIP transaction manager in a background thread
|
||||
@@ -499,23 +553,53 @@ class SippyEngine(SIPEngine):
|
||||
def do_register():
|
||||
try:
|
||||
from sippy.SipRegistrationAgent import SipRegistrationAgent
|
||||
from sippy.SipURL import SipURL
|
||||
|
||||
def on_registered(_rtime, _contact, _cb_arg):
|
||||
logger.info(" ✅ Trunk registration accepted")
|
||||
self._post_from_ed("trunk_registered", {"registered": True})
|
||||
|
||||
def on_register_failed(status_line, _cb_arg):
|
||||
# status_line is the response's status line (e.g. "403
|
||||
# Forbidden") — surface it; a bad trunk password is the
|
||||
# most common cause and is otherwise invisible.
|
||||
logger.error(f" ❌ Trunk registration rejected: {status_line}")
|
||||
self._post_from_ed(
|
||||
"trunk_registered",
|
||||
{"registered": False, "reason": str(status_line)},
|
||||
)
|
||||
|
||||
# A wildcard bind is not a routable Contact — the trunk would
|
||||
# have nowhere to send the inbound INVITE. Fall back to
|
||||
# loopback, matching _generate_sdp's handling.
|
||||
contact_host = (
|
||||
self._sip_address if self._sip_address != "0.0.0.0" else "127.0.0.1"
|
||||
)
|
||||
|
||||
# aor/contact must be SipURL objects: the agent calls
|
||||
# .getCopy() and mutates .username/.port on them.
|
||||
reg_agent = SipRegistrationAgent(
|
||||
self._sippy_global_config,
|
||||
f"sip:{self._trunk_username}@{self._trunk_host}",
|
||||
f"sip:{self._trunk_host}:{self._trunk_port}",
|
||||
auth_name=self._trunk_username,
|
||||
auth_password=self._trunk_password,
|
||||
SipURL(f"sip:{self._trunk_username}@{self._trunk_host}"),
|
||||
SipURL(f"sip:{self._trunk_username}@{contact_host}:{self._sip_port}"),
|
||||
user=self._trunk_username,
|
||||
passw=self._trunk_password,
|
||||
rok_cb=on_registered,
|
||||
rfail_cb=on_register_failed,
|
||||
)
|
||||
reg_agent.register()
|
||||
logger.info(" ✅ Trunk registration sent")
|
||||
self._post_from_ed("trunk_registered", {"registered": True})
|
||||
# Registration is asynchronous: success is reported by the
|
||||
# callbacks above, not here. Reporting "registered" at send
|
||||
# time would let /health go green on a rejected REGISTER.
|
||||
reg_agent.doregister()
|
||||
logger.info(" Trunk REGISTER sent, awaiting response")
|
||||
except ImportError:
|
||||
logger.warning(" Sippy registration agent not available")
|
||||
self._post_from_ed("trunk_registered", {"registered": False})
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Trunk registration failed: {e}")
|
||||
self._post_from_ed("trunk_registered", {"registered": False})
|
||||
logger.error(f" ❌ Trunk registration failed: {e}", exc_info=True)
|
||||
self._post_from_ed(
|
||||
"trunk_registered", {"registered": False, "reason": str(e)}
|
||||
)
|
||||
|
||||
self._run_on_sippy(do_register)
|
||||
|
||||
@@ -570,7 +654,8 @@ class SippyEngine(SIPEngine):
|
||||
else:
|
||||
remote_uri = f"sip:{number}@{self._domain}"
|
||||
|
||||
from_uri = f"sip:{caller_id or self._did}@{self._domain}"
|
||||
caller_number = caller_id or self._did
|
||||
from_uri = f"sip:{caller_number}@{self._domain}"
|
||||
|
||||
leg = SipCallLeg(leg_id, "outbound", remote_uri)
|
||||
self._legs[leg_id] = leg
|
||||
@@ -583,24 +668,38 @@ class SippyEngine(SIPEngine):
|
||||
def do_invite():
|
||||
try:
|
||||
from sippy.CCEvents import CCEventTry
|
||||
from sippy.MsgBody import MsgBody
|
||||
from sippy.SipCallId import SipCallId
|
||||
from sippy.UA import UA
|
||||
|
||||
controller = SippyCallController(leg_id, self)
|
||||
|
||||
# Create Sippy UA for this call
|
||||
# Create Sippy UA for this call. The credentials are required:
|
||||
# a trunk answers the first INVITE with 401/407, and sippy
|
||||
# only retries with a digest response when they are set —
|
||||
# without them every outbound call dies on the challenge.
|
||||
ua = UA(
|
||||
self._sippy_global_config,
|
||||
event_cb=controller,
|
||||
username=self._trunk_username or None,
|
||||
password=self._trunk_password or None,
|
||||
nh_address=(self._trunk_host, self._trunk_port),
|
||||
)
|
||||
self._ed_leg_to_ua[leg_id] = ua
|
||||
self._ed_ua_to_leg[ua] = leg_id
|
||||
|
||||
# Send INVITE
|
||||
# SDP travels inside the event's data tuple as a MsgBody, not
|
||||
# as a kwarg. needs_update=False marks it final: with it set,
|
||||
# sippy would call ua.on_local_sdp_change (unset here) before
|
||||
# sending, and the INVITE would never go out.
|
||||
body = MsgBody(sdp_body, mtype="application/sdp")
|
||||
body.needs_update = False
|
||||
|
||||
# UacStateIdle unpacks exactly six fields and builds the SIP
|
||||
# URIs itself from nh_address — callingID/calledID are bare
|
||||
# usernames, not full URIs.
|
||||
event = CCEventTry(
|
||||
(SipCallId(), from_uri, remote_uri),
|
||||
body=sdp_body,
|
||||
(SipCallId(), caller_number, number, body, None, None)
|
||||
)
|
||||
ua.recvEvent(event)
|
||||
|
||||
@@ -613,8 +712,11 @@ class SippyEngine(SIPEngine):
|
||||
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "ringing"})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to send INVITE for {leg_id}: {e}")
|
||||
self._post_from_ed("leg_state", {"leg_id": leg_id, "state": "terminated"})
|
||||
logger.error(f" Failed to send INVITE for {leg_id}: {e}", exc_info=True)
|
||||
self._post_from_ed(
|
||||
"leg_state",
|
||||
{"leg_id": leg_id, "state": "terminated", "error": str(e)},
|
||||
)
|
||||
|
||||
self._run_on_sippy(do_invite)
|
||||
return leg_id
|
||||
|
||||
11
dashboard/package-lock.json
generated
11
dashboard/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/node": "^25.8.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
@@ -1262,6 +1263,16 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.0.tgz",
|
||||
"integrity": "sha512-2/kYbxaKtv349lPrTyxMKC9SHsyA7fBULMSabJljDE82D079cjqz+UyAzsogWgy4sTs5NDvD000acfcFqbO1XA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/node": "^25.8.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"svelte": "^5.25.3",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^4.1.3",
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui' {
|
||||
themes: light --default, dark --prefersdark;
|
||||
}
|
||||
|
||||
5
dashboard/src/hooks.client.ts
Normal file
5
dashboard/src/hooks.client.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
// Runs once, before the app mounts: capture the Casdoor callback token from
|
||||
// the URL fragment before any /auth/me call or render.
|
||||
auth.captureFragmentToken();
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AccessToken,
|
||||
CallHistoryRow,
|
||||
CallSummary,
|
||||
DeviceStatus,
|
||||
@@ -10,19 +11,12 @@ import type {
|
||||
} from './types';
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Bearer token — one static API_TOKEN shared with REST/WS/MCP.
|
||||
// Kept in localStorage; a 401 prompts once and retries.
|
||||
// Authed fetch — the browser holds a Casdoor JWT (or, for scripted use,
|
||||
// a PAT) in localStorage via the auth store. On a 401 we attempt one
|
||||
// silent refresh and retry; a second failure logs out.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
const TOKEN_KEY = 'hold-slayer-token';
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setToken(value: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, value);
|
||||
}
|
||||
import { auth, getToken } from './auth.svelte';
|
||||
|
||||
function withAuth(init: RequestInit): RequestInit {
|
||||
const token = getToken();
|
||||
@@ -36,10 +30,11 @@ function withAuth(init: RequestInit): RequestInit {
|
||||
async function request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
let res = await fetch(path, withAuth(init));
|
||||
if (res.status === 401) {
|
||||
const supplied = window.prompt('Hold Slayer API token (API_TOKEN in .env):');
|
||||
if (supplied !== null && supplied.trim()) {
|
||||
setToken(supplied.trim());
|
||||
const refreshed = await auth.trySilentRefresh();
|
||||
if (refreshed) {
|
||||
res = await fetch(path, withAuth(init));
|
||||
} else {
|
||||
auth.setUnauthenticated();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
@@ -89,8 +84,10 @@ export async function fetchTranscript(callId: string): Promise<TranscriptRow[]>
|
||||
}
|
||||
|
||||
export function recordingUrl(callId: string): string {
|
||||
// <audio> can't send headers, so the token rides as a query param
|
||||
// (accepted server-side alongside the Authorization header).
|
||||
// <audio> can't send headers, so the current token (Casdoor JWT, or a PAT)
|
||||
// rides as a query param — the same narrow fallback the WebSocket uses,
|
||||
// accepted server-side alongside the Authorization header. The proactive
|
||||
// refresh timer keeps the stored JWT valid, so it's fresh at click time.
|
||||
const token = getToken();
|
||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return `/api/v1/calls/${callId}/recording${suffix}`;
|
||||
@@ -137,11 +134,35 @@ export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Personal Access Tokens (owner-only) — for MCP/CLI clients.
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
export async function fetchTokens(): Promise<AccessToken[]> {
|
||||
return get<AccessToken[]>('/api/v1/tokens');
|
||||
}
|
||||
|
||||
export async function createToken(name: string): Promise<AccessToken> {
|
||||
const res = await request('/api/v1/tokens', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json() as Promise<AccessToken>;
|
||||
}
|
||||
|
||||
export async function revokeToken(tokenId: string): Promise<void> {
|
||||
const res = await request(`/api/v1/tokens/${tokenId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
export function connectEventStream(
|
||||
onEvent: (e: GatewayEvent) => void,
|
||||
onClose: () => void,
|
||||
): () => void {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// Browsers can't set headers on WS connects — the token rides as ?token=.
|
||||
const token = getToken();
|
||||
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/events${suffix}`);
|
||||
|
||||
146
dashboard/src/lib/auth.svelte.ts
Normal file
146
dashboard/src/lib/auth.svelte.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { User } from './types';
|
||||
|
||||
const TOKEN_KEY = 'hold-slayer-token';
|
||||
|
||||
// 'denied' = authenticated with Casdoor but not the owner of this gateway.
|
||||
export type AuthStatus = 'loading' | 'authed' | 'unauthenticated' | 'denied';
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) || '';
|
||||
}
|
||||
|
||||
function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
class AuthStore {
|
||||
status = $state<AuthStatus>('loading');
|
||||
user = $state<User | null>(null);
|
||||
|
||||
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
get isOwner(): boolean {
|
||||
return this.user?.is_owner ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a `#token=...` fragment left by the Casdoor callback, persist it,
|
||||
* and scrub it from the URL. Runs before the first /auth/me call.
|
||||
*/
|
||||
captureFragmentToken() {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#token=')) {
|
||||
const token = hash.slice(7);
|
||||
if (token) setToken(token);
|
||||
history.replaceState(null, '', window.location.pathname + window.location.search);
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine auth state on boot. */
|
||||
async init(): Promise<void> {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
// No token: maybe SSO is disabled (dev mode) — /auth/me succeeds tokenless.
|
||||
try {
|
||||
const res = await fetch('/auth/me');
|
||||
if (res.ok) {
|
||||
this.applyUser(await res.json());
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to unauthenticated */
|
||||
}
|
||||
this.status = 'unauthenticated';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Token invalid — try silent refresh once, then retry.
|
||||
const refreshed = await this.trySilentRefresh();
|
||||
if (refreshed) return this.init();
|
||||
clearToken();
|
||||
this.status = 'unauthenticated';
|
||||
return;
|
||||
}
|
||||
this.applyUser(await res.json());
|
||||
this.scheduleTokenRefresh(token);
|
||||
} catch {
|
||||
this.status = 'unauthenticated';
|
||||
}
|
||||
}
|
||||
|
||||
/** Set user + status from an /auth/me payload. Non-owners are denied. */
|
||||
private applyUser(user: User) {
|
||||
this.user = user;
|
||||
this.status = user.is_owner ? 'authed' : 'denied';
|
||||
}
|
||||
|
||||
setUnauthenticated() {
|
||||
clearToken();
|
||||
this.user = null;
|
||||
this.status = 'unauthenticated';
|
||||
}
|
||||
|
||||
/** Silent token refresh via a hidden iframe + postMessage. */
|
||||
trySilentRefresh(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.display = 'none';
|
||||
iframe.src = '/auth/silent-refresh';
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
window.removeEventListener('message', onMessage);
|
||||
iframe.remove();
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (!event.data || event.data.type !== 'hold-slayer-refresh') return;
|
||||
cleanup();
|
||||
if (event.data.token) {
|
||||
setToken(event.data.token);
|
||||
this.scheduleTokenRefresh(event.data.token);
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Proactively refresh 5 minutes before JWT expiry. PATs are skipped. */
|
||||
scheduleTokenRefresh(token: string) {
|
||||
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const exp = payload.exp * 1000;
|
||||
const refreshIn = Math.max(exp - Date.now() - 5 * 60 * 1000, 30 * 1000);
|
||||
this.refreshTimer = setTimeout(async () => {
|
||||
const ok = await this.trySilentRefresh();
|
||||
if (!ok) this.setUnauthenticated();
|
||||
}, refreshIn);
|
||||
} catch {
|
||||
// Not a JWT (e.g. a PAT) — no refresh needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = new AuthStore();
|
||||
17
dashboard/src/lib/components/DeniedScreen.svelte
Normal file
17
dashboard/src/lib/components/DeniedScreen.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div class="card bg-base-200 w-96 shadow-xl">
|
||||
<div class="card-body items-center gap-4 text-center">
|
||||
<h1 class="text-2xl font-bold">Not authorized</h1>
|
||||
<p class="text-sm opacity-70">
|
||||
Hold Slayer is a single-operator gateway. You're signed in as
|
||||
<span class="font-medium">{auth.user?.display_name ?? auth.user?.name}</span>,
|
||||
but this gateway is reserved for its owner.
|
||||
</p>
|
||||
<a href="/auth/logout" class="btn btn-outline w-full">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
17
dashboard/src/lib/components/LoginScreen.svelte
Normal file
17
dashboard/src/lib/components/LoginScreen.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
// A full-screen sign-in gate. The anchor is a real navigation to the
|
||||
// server-side /auth/login route (which 302s to Casdoor), not a fetch.
|
||||
</script>
|
||||
|
||||
<div class="bg-base-100 fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div class="card bg-base-200 w-80 shadow-xl">
|
||||
<div class="card-body items-center gap-4 text-center">
|
||||
<h1 class="flex items-center justify-center gap-2 text-3xl font-bold">
|
||||
<span class="text-orange-500">🔥</span>
|
||||
Hold Slayer
|
||||
</h1>
|
||||
<p class="text-sm opacity-60">Sign in to the gateway</p>
|
||||
<a href="/auth/login" class="btn btn-primary w-full">Sign in with SSO</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
162
dashboard/src/lib/components/TokensModal.svelte
Normal file
162
dashboard/src/lib/components/TokensModal.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { AccessToken } from '$lib/types';
|
||||
import { createToken, fetchTokens, revokeToken } from '$lib/api';
|
||||
|
||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||
|
||||
let tokens = $state<AccessToken[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let newName = $state('');
|
||||
let creating = $state(false);
|
||||
// The plaintext of a just-created token — shown once, never re-fetchable.
|
||||
let created = $state<AccessToken | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tokens = await fetchTokens();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
created = null;
|
||||
void load();
|
||||
}
|
||||
});
|
||||
|
||||
async function create() {
|
||||
if (!newName.trim()) return;
|
||||
creating = true;
|
||||
error = null;
|
||||
try {
|
||||
created = await createToken(newName.trim());
|
||||
newName = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await revokeToken(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function mcpConfig(plaintext: string): string {
|
||||
const url = `${location.origin}/mcp`;
|
||||
return JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
'hold-slayer': {
|
||||
type: 'streamable-http',
|
||||
url,
|
||||
headers: { Authorization: `Bearer ${plaintext}` }
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
void navigator.clipboard.writeText(text);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold">API Tokens</h3>
|
||||
<p class="py-1 text-sm opacity-60">
|
||||
Personal access tokens for MCP/CLI clients (Claude Desktop, Cline). The
|
||||
plaintext is shown once — store it now.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error my-2 text-sm">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if created?.token}
|
||||
<div class="alert alert-success my-3 flex-col items-start gap-2">
|
||||
<span class="font-medium">Token created — copy it now, it won't be shown again.</span>
|
||||
<code class="bg-base-300 w-full break-all rounded p-2 text-xs">{created.token}</code>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-xs" onclick={() => copy(created!.token!)}>Copy token</button>
|
||||
<button class="btn btn-xs" onclick={() => copy(mcpConfig(created!.token!))}
|
||||
>Copy MCP config</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="my-3 flex gap-2">
|
||||
<input
|
||||
class="input input-bordered flex-1"
|
||||
placeholder="Token name (e.g. Claude Desktop)"
|
||||
bind:value={newName}
|
||||
onkeydown={(e) => e.key === 'Enter' && create()}
|
||||
/>
|
||||
<button class="btn btn-primary" disabled={creating || !newName.trim()} onclick={create}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="py-4 text-center opacity-60">Loading…</div>
|
||||
{:else if tokens.length === 0}
|
||||
<div class="py-4 text-center opacity-60">No tokens yet.</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Prefix</th>
|
||||
<th>Last used</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tokens as t (t.id)}
|
||||
<tr class:opacity-50={t.revoked_at}>
|
||||
<td>{t.name}</td>
|
||||
<td><code class="text-xs">{t.token_prefix}…</code></td>
|
||||
<td class="text-xs">{t.last_used_at?.slice(0, 10) ?? '—'}</td>
|
||||
<td class="text-right">
|
||||
{#if t.revoked_at}
|
||||
<span class="badge badge-ghost badge-sm">revoked</span>
|
||||
{:else}
|
||||
<button class="btn btn-ghost btn-xs text-error" onclick={() => revoke(t.id)}>
|
||||
Revoke
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn" onclick={() => (open = false)}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="modal-backdrop" onclick={() => (open = false)} aria-label="Close"></button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,3 +1,23 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
email: string | null;
|
||||
is_owner: boolean;
|
||||
}
|
||||
|
||||
export interface AccessToken {
|
||||
id: string;
|
||||
name: string;
|
||||
token_prefix: string;
|
||||
created_at: string | null;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
revoked_at: string | null;
|
||||
// Present only in the create response — the plaintext, shown once.
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface GatewayStatus {
|
||||
name: string;
|
||||
version: string;
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
import '../app.css';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import LoginScreen from '$lib/components/LoginScreen.svelte';
|
||||
import DeniedScreen from '$lib/components/DeniedScreen.svelte';
|
||||
import TokensModal from '$lib/components/TokensModal.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let tokensOpen = $state(false);
|
||||
|
||||
type ThemeOverride = 'dark' | 'light' | null;
|
||||
let override = $state<ThemeOverride>(null);
|
||||
let systemDark = $state(true);
|
||||
@@ -12,7 +18,10 @@
|
||||
let isDark = $derived(override !== null ? override === 'dark' : systemDark);
|
||||
|
||||
$effect(() => {
|
||||
// Keep both theming systems in sync: Tailwind `dark:` variant (.dark class)
|
||||
// for the existing pages, and DaisyUI `data-theme` for the SSO components.
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
@@ -27,6 +36,8 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void auth.init();
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
systemDark = mq.matches;
|
||||
|
||||
@@ -49,7 +60,16 @@
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||
{#if auth.status === 'loading'}
|
||||
<div class="fixed inset-0 flex items-center justify-center bg-slate-50 dark:bg-gray-950">
|
||||
<span class="loading loading-spinner loading-lg text-orange-500"></span>
|
||||
</div>
|
||||
{:else if auth.status === 'unauthenticated'}
|
||||
<LoginScreen />
|
||||
{:else if auth.status === 'denied'}
|
||||
<DeniedScreen />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||
<header
|
||||
class="border-b border-gray-200 dark:border-gray-800 bg-white/80 dark:bg-gray-900/80 backdrop-blur sticky top-0 z-10"
|
||||
>
|
||||
@@ -72,17 +92,31 @@
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="ml-auto text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
class="text-xs px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{isDark ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-sm">
|
||||
{auth.user?.display_name ?? auth.user?.name ?? 'Owner'}
|
||||
</button>
|
||||
<ul class="dropdown-content menu bg-base-200 rounded-box z-20 w-48 p-2 shadow">
|
||||
<li><button onclick={() => (tokensOpen = true)}>API Tokens</button></li>
|
||||
<li><a href="/auth/logout">Sign out</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="mx-auto max-w-7xl px-4 py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TokensModal bind:open={tokensOpen} />
|
||||
{/if}
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import (
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
@@ -153,6 +154,48 @@ class RecordingRecord(Base):
|
||||
return f"<Recording {self.id} call={self.call_id} {self.path}>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""An SSO-provisioned identity. The gateway is owner-only: the single
|
||||
owner is the user whose `name` matches settings.owner_name; everyone else
|
||||
is created on first login but reaches nothing (403 on every surface)."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String, primary_key=True) # uuid4().hex, set in Python
|
||||
name = Column(String, nullable=False) # Casdoor username — owner-match key
|
||||
display_name = Column(String, nullable=True) # Casdoor display name (UI only)
|
||||
email = Column(String, nullable=True, unique=True)
|
||||
casdoor_sub = Column(String, nullable=True, unique=True) # OIDC subject claim
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.id} {self.name}>"
|
||||
|
||||
|
||||
class PersonalAccessToken(Base):
|
||||
"""Long-lived bearer token for API/MCP clients (Claude Desktop, Cline)
|
||||
that can't refresh a JWT. Plaintext is shown once at creation; only the
|
||||
SHA-256 hash is persisted. Soft-revoked by setting revoked_at."""
|
||||
|
||||
__tablename__ = "personal_access_tokens"
|
||||
|
||||
id = Column(String, primary_key=True) # uuid4().hex
|
||||
user_id = Column(
|
||||
String, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name = Column(String, nullable=False)
|
||||
token_hash = Column(String, nullable=False, unique=True, index=True)
|
||||
token_prefix = Column(String, nullable=False) # for display, not a secret
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
revoked_at = Column(DateTime, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PersonalAccessToken {self.id} user={self.user_id}>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Engine & Session
|
||||
# ============================================================
|
||||
|
||||
55
db/migrations/versions/a1b2c3d4e5f6_users_and_pats.py
Normal file
55
db/migrations/versions/a1b2c3d4e5f6_users_and_pats.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""users and personal access tokens
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 5187577efc23
|
||||
Create Date: 2026-07-22 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '5187577efc23'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('display_name', sa.String(), nullable=True),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('casdoor_sub', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email'),
|
||||
sa.UniqueConstraint('casdoor_sub')
|
||||
)
|
||||
op.create_table('personal_access_tokens',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('token_hash', sa.String(), nullable=False),
|
||||
sa.Column('token_prefix', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('token_hash')
|
||||
)
|
||||
op.create_index(op.f('ix_personal_access_tokens_token_hash'), 'personal_access_tokens', ['token_hash'], unique=True)
|
||||
op.create_index(op.f('ix_personal_access_tokens_user_id'), 'personal_access_tokens', ['user_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_personal_access_tokens_user_id'), table_name='personal_access_tokens')
|
||||
op.drop_index(op.f('ix_personal_access_tokens_token_hash'), table_name='personal_access_tokens')
|
||||
op.drop_table('personal_access_tokens')
|
||||
op.drop_table('users')
|
||||
84
docker-compose.yaml
Normal file
84
docker-compose.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
# Local Hold Slayer stack: the single app image + its own PostgreSQL.
|
||||
#
|
||||
# Hold Slayer is ONE FastAPI process exposing REST/WS/MCP and serving its built
|
||||
# SvelteKit dashboard at "/" — no separate web/nginx service (the Dockerfile's
|
||||
# node stage builds the dashboard into the image).
|
||||
#
|
||||
# Auth is Casdoor SSO (owner-only). Because the published port binds the app to
|
||||
# 0.0.0.0, dev-owner mode (CASDOOR_ENABLED=false) is intentionally REFUSED at
|
||||
# startup here — that mode is loopback-only. So the stack expects the CASDOOR_*
|
||||
# + OWNER_NAME vars set (see .env.compose.example). MCP/CLI clients then use an
|
||||
# owner-minted PAT.
|
||||
#
|
||||
# cp .env.compose.example .env
|
||||
# # fill in CASDOOR_* + OWNER_NAME (+ HS_DB_PASSWORD)
|
||||
# docker compose up --build
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_USER: ${HS_DB_USER:-holdslayer}
|
||||
POSTGRES_PASSWORD: ${HS_DB_PASSWORD:?set HS_DB_PASSWORD in .env}
|
||||
POSTGRES_DB: ${HS_DB_NAME:-holdslayer}
|
||||
volumes:
|
||||
- hs_pgdata:/var/lib/postgresql/data
|
||||
# json-file + Alloy docker-socket discovery is the estate pattern; no
|
||||
# syslog driver / 514xx listener (which would block container creation when
|
||||
# the listener is absent). See ouranos Rosalind/Virgo logging convention.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${HS_DB_USER:-holdslayer} -d ${HS_DB_NAME:-holdslayer}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build: .
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Migrations run in the app's own init_db() on boot; it just needs to
|
||||
# reach the db service. asyncpg URL points at the compose service name.
|
||||
DATABASE_URL: postgresql+asyncpg://${HS_DB_USER:-holdslayer}:${HS_DB_PASSWORD}@db:5432/${HS_DB_NAME:-holdslayer}
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "21081"
|
||||
# Mock SIP: this stack is a dev/local deploy, not a real trunk. /health
|
||||
# honestly reports engine=mock as "degraded". Flip to false + fill the
|
||||
# SIP_TRUNK_* vars for a real-trunk deploy.
|
||||
USE_MOCK_SIP: ${USE_MOCK_SIP:-true}
|
||||
# --- Auth: Casdoor SSO (owner-only) ---
|
||||
CASDOOR_ENABLED: ${CASDOOR_ENABLED:-true}
|
||||
CASDOOR_ENDPOINT: ${CASDOOR_ENDPOINT:-https://id.ouranos.helu.ca}
|
||||
CASDOOR_CLIENT_ID: ${CASDOOR_CLIENT_ID}
|
||||
CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET}
|
||||
CASDOOR_ORG_NAME: ${CASDOOR_ORG_NAME:-heluca}
|
||||
CASDOOR_APP_NAME: ${CASDOOR_APP_NAME:-hold-slayer}
|
||||
OWNER_NAME: ${OWNER_NAME:?set OWNER_NAME (the owner's Casdoor username)}
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
||||
ports:
|
||||
- "${HS_APP_PORT:-21081}:21081"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
# /health returns 200 even when "degraded" (mock engine / unregistered
|
||||
# trunk) — so a 200 means the process is up and serving, which is the
|
||||
# right liveness signal for a mock-SIP dev stack.
|
||||
test: ["CMD", "curl", "-f", "http://localhost:21081/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hs_pgdata:
|
||||
@@ -8,6 +8,9 @@ Comprehensive documentation for the Hold Slayer AI telephony gateway.
|
||||
|----------|-------------|
|
||||
| [Architecture](architecture.md) | System architecture, component diagram, data flow |
|
||||
| [Core Engine](core-engine.md) | SIP engine, media pipeline, call manager, event bus |
|
||||
| [Dial Plan](dial-plan.md) | Number normalisation and the emergency-number guard |
|
||||
| [PJSUA2 Build](pjsua2-build.md) | Building the `pjsua2` bindings (not pip-installable) |
|
||||
| [Asterisk Lab](../tests/lab/README.md) | The fake PSTN used for media validation |
|
||||
| [Hold Slayer Service](hold-slayer-service.md) | IVR navigation, hold detection, human detection, transfer |
|
||||
| [Audio Classifier](audio-classifier.md) | Waveform analysis, feature extraction, classification logic |
|
||||
| [Services](services.md) | LLM client, transcription, recording, analytics, notifications |
|
||||
|
||||
@@ -274,10 +274,21 @@ All errors follow a consistent format:
|
||||
| Status Code | Meaning |
|
||||
|-------------|---------|
|
||||
| `400` | Bad request (invalid parameters) |
|
||||
| `401` | Not authenticated (missing or invalid bearer token) |
|
||||
| `403` | Authenticated but not the owner |
|
||||
| `404` | Resource not found (call, flow, device) |
|
||||
| `409` | Conflict (call already ended, device already registered) |
|
||||
| `429` | Rate limited — `/auth/*` routes only. Carries `Retry-After` (seconds) |
|
||||
| `500` | Internal server error |
|
||||
|
||||
`429` applies solely to the unauthenticated `/auth/*` edge: those routes must
|
||||
answer before an identity exists, and each does real work (`/auth/callback`
|
||||
makes an outbound token exchange with Casdoor, `/auth/me` opens a DB session).
|
||||
The owner-gated API is not rate limited — it is already restricted to a single
|
||||
operator. Limits are per client, per route, in a fixed 60-second window; the
|
||||
client is the **socket peer**, so behind a reverse proxy the limit applies
|
||||
per-proxy. See [core/rate_limit.py](../core/rate_limit.py).
|
||||
|
||||
## WebSocket
|
||||
|
||||
### Event Stream
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
# Architecture
|
||||
|
||||
Hold Slayer is a single-process async Python application built on FastAPI. It acts as an intelligent B2BUA (Back-to-Back User Agent) sitting between your SIP trunk (PSTN access) and your desk phone/softphone.
|
||||
Hold Slayer is a single-process async Python application built on FastAPI. It
|
||||
acts as an intelligent B2BUA (Back-to-Back User Agent) sitting between your SIP
|
||||
trunk (PSTN access) and your desk phone/softphone.
|
||||
|
||||
> **Two SIP engines, selected by `SIP_ENGINE`.** `sippy` (the default) signals
|
||||
> only — PJSUA2 will not surface an RTP stream for a dialog it does not own, so
|
||||
> **no audio reaches the classifier** on that path. `pjsua2`
|
||||
> (`core/pjsua_engine.py`) places the call itself and is the only mode where
|
||||
> audio reaches the classifier; it is opt-in while being proven against the lab.
|
||||
> Read [Media plane: why PJSUA2 places the call](#media-plane-why-pjsua2-places-the-call)
|
||||
> before changing anything in `core/`.
|
||||
|
||||
## System Diagram
|
||||
|
||||
@@ -27,8 +37,13 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
│ └────┬─────┘ └─────┬─────┘ └──────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌────┴──────────────┴───────────────────┐ │
|
||||
│ │ Sippy B2BUA Engine │ │
|
||||
│ │ (SIP calls, DTMF, conference bridge) │ │
|
||||
│ │ SIP Engine │ │
|
||||
│ │ signalling + call control │ │
|
||||
│ └────┬──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────┴──────────────────────────────────┐ │
|
||||
│ │ Media Pipeline (PJSUA2) │ │
|
||||
│ │ RTP, conference bridge, taps, record │ │
|
||||
│ └────┬──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
└───────┼─────────────────────────────────────────────────────────┘
|
||||
@@ -70,8 +85,8 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| Sippy Engine | `core/sippy_engine.py` | SIP signaling (INVITE, BYE, REGISTER, DTMF) |
|
||||
| Media Pipeline | `core/media_pipeline.py` | PJSUA2 RTP media handling, conference bridge, recording |
|
||||
| Sippy Engine | `core/sippy_engine.py` | SIP signalling (INVITE, BYE, REGISTER, DTMF) |
|
||||
| Media Pipeline | `core/media_pipeline.py` | PJSUA2 RTP media, conference bridge, taps, recording |
|
||||
| Recording | `services/recording.py` | WAV file management and storage |
|
||||
| Analytics | `services/call_analytics.py` | Call metrics, hold time stats, trends |
|
||||
| Notifications | `services/notification.py` | WebSocket + SMS alerts |
|
||||
@@ -84,9 +99,10 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
POST /api/v1/calls/hold-slayer { number, intent, call_flow_id }
|
||||
│
|
||||
2. Gateway.make_call()
|
||||
├── is_emergency_number() → REFUSE 911/112 (before anything else)
|
||||
├── concurrency cap check → refuse past max_concurrent_calls
|
||||
├── CallManager.create_call() → track state
|
||||
├── SippyEngine.make_call() → SIP INVITE to trunk
|
||||
└── MediaPipeline.add_stream() → RTP media setup
|
||||
└── sip_engine.make_call() → place the call, media follows
|
||||
│
|
||||
3. HoldSlayer.run_with_flow() or run_exploration()
|
||||
├── AudioClassifier.classify() → analyze 3s audio windows
|
||||
@@ -99,14 +115,14 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
├── TranscriptionService.transcribe() → STT on speech audio
|
||||
│
|
||||
├── LLMClient.analyze_ivr_menu() → pick menu option (fallback)
|
||||
│ └── SippyEngine.send_dtmf() → press the button
|
||||
│ └── sip_engine.send_dtmf() → press the button
|
||||
│
|
||||
└── detect_hold_to_human_transition()
|
||||
└── HUMAN_DETECTED! → transfer
|
||||
│
|
||||
4. Transfer
|
||||
├── SippyEngine.bridge() → connect call legs
|
||||
├── MediaPipeline.bridge_streams() → bridge RTP
|
||||
├── SippyEngine.bridge_calls() → join the two call legs
|
||||
├── MediaPipeline.bridge_streams() → bridge RTP in the conf bridge
|
||||
├── EventBus.publish(TRANSFER_STARTED)
|
||||
└── NotificationService → "Pick up your phone!"
|
||||
│
|
||||
@@ -117,44 +133,95 @@ Hold Slayer is a single-process async Python application built on FastAPI. It ac
|
||||
→ Analytics tracking
|
||||
```
|
||||
|
||||
The emergency guard and the concurrency cap are the first two steps of
|
||||
`make_call` for a reason, and their order is load-bearing — see
|
||||
[.claude/rules/call-safety.md](../.claude/rules/call-safety.md).
|
||||
|
||||
## Threading Model
|
||||
|
||||
Hold Slayer is primarily single-threaded async (asyncio), with one exception:
|
||||
|
||||
- **Main thread**: FastAPI + all async services (event bus, hold slayer, classifier, etc.)
|
||||
- **Sippy thread**: Sippy B2BUA runs its own event loop in a dedicated daemon thread. The `SippyEngine` bridges async↔sync via `asyncio.run_in_executor()`.
|
||||
- **PJSUA2**: Runs in the main thread using null audio device (no sound card needed — headless server mode).
|
||||
The README's "single-process async" is a simplification. There are **three**
|
||||
execution contexts, and the boundaries between them are the highest-leverage
|
||||
invariant in the codebase.
|
||||
|
||||
```
|
||||
Main Thread (asyncio)
|
||||
├── FastAPI (uvicorn)
|
||||
├── EventBus
|
||||
├── CallManager
|
||||
├── HoldSlayer
|
||||
asyncio loop (main thread) Sippy ED thread PJSUA2 worker threads
|
||||
├── FastAPI (uvicorn) └── ED2 dispatcher └── media / RTP
|
||||
├── EventBus ├── SIP signalling └── onFrameReceived
|
||||
├── CallManager ├── UA objects
|
||||
├── HoldSlayer └── DTMF relay
|
||||
├── AudioClassifier
|
||||
├── TranscriptionService
|
||||
├── LLMClient
|
||||
├── MediaPipeline (PJSUA2)
|
||||
├── NotificationService
|
||||
└── RecordingService
|
||||
|
||||
Sippy Thread (daemon)
|
||||
└── Sippy B2BUA event loop
|
||||
├── SIP signaling
|
||||
├── DTMF relay
|
||||
└── Call leg management
|
||||
```
|
||||
|
||||
**Crossing the boundaries — one funnel each way:**
|
||||
|
||||
| Direction | Mechanism | Notes |
|
||||
|---|---|---|
|
||||
| Sippy ED → loop | `_post_from_ed` → `asyncio.run_coroutine_threadsafe` → `_on_engine_event` | The single funnel where Sippy-thread events mutate loop state |
|
||||
| loop → Sippy ED | `_run_on_sippy` → `ED2.callFromThread` | Anything touching a Sippy UA object |
|
||||
| PJSUA2 worker → loop | `AudioTap.feed` → `loop.call_soon_threadsafe` | The **only** thing a PJSUA2 callback may touch |
|
||||
|
||||
`onFrameReceived` runs on a PJSUA2 worker thread every 20 ms. It must call
|
||||
nothing but `AudioTap.feed`; reaching into pipeline state, the event bus, or a
|
||||
Sippy object from there is a data race. An exception escaping into PJSUA2's C++
|
||||
callback tears down the worker thread and silently kills media for every call,
|
||||
which is why the capture port catches and logs once rather than per frame.
|
||||
|
||||
Full detail: [.claude/rules/concurrency-threads.md](../.claude/rules/concurrency-threads.md).
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why Sippy B2BUA + PJSUA2?
|
||||
### Media plane: why PJSUA2 places the call
|
||||
|
||||
We split SIP signaling and media handling into two separate libraries:
|
||||
The original split was *Sippy signals, PJSUA2 carries media*. It does not work,
|
||||
for a reason that is not obvious until you try it:
|
||||
|
||||
- **Sippy B2BUA** handles SIP signaling (INVITE, BYE, REGISTER, re-INVITE, DTMF relay). It's battle-tested for telephony and handles the complex SIP state machine.
|
||||
- **PJSUA2** handles RTP media (audio streams, conference bridge, recording, tone generation). It provides a clean C++/Python API for media manipulation without needing to deal with raw RTP.
|
||||
**PJSUA2 exposes no standalone RTP media object.** Every `AudioMedia` subclass
|
||||
in the Python bindings is a file player, recorder, tone generator, or capture
|
||||
port. RTP is reachable only through `pj.Call.getAudioMedia()`, after
|
||||
`onCallMediaState` fires on a dialog **PJSUA2 itself owns**. There is no
|
||||
"give me an AudioMedia for this remote host:port" API to call.
|
||||
|
||||
This split lets us tap into the audio stream (for classification and STT) without interfering with SIP signaling, and bridge calls through a conference bridge for clean transfer.
|
||||
So a design where Sippy owns the dialog can never obtain a media stream from
|
||||
PJSUA2. `MediaPipeline.add_remote_stream()` is not unfinished work — it is a
|
||||
function that cannot be written against this API. The consequence is that audio
|
||||
never reaches the classifier: `create_tap` builds a valid capture port with
|
||||
nothing to attach it to.
|
||||
|
||||
**The resolution: PJSUA2 places the call; Sippy keeps every other role.**
|
||||
|
||||
| Concern | Owner |
|
||||
|---|---|
|
||||
| Emergency guard, concurrency cap | `gateway.make_call` — unchanged, still first |
|
||||
| Trunk registration | PJSUA2 `Account` |
|
||||
| Outbound INVITE / answer / hangup | PJSUA2 `Call` |
|
||||
| RTP, conference bridge, taps, recording | PJSUA2 media |
|
||||
| DTMF | PJSUA2 `Call.dialDtmf` (RFC 2833) |
|
||||
| Device registration, routing, leg bridging | Sippy / gateway |
|
||||
| Inbound call dispatch | PJSUA2 `Account.onIncomingCall` |
|
||||
|
||||
Sippy remains the SBC-shaped layer — it is where device registrations, routing
|
||||
decisions and B2BUA leg-joining live. What moves is the raw dialog for a trunk
|
||||
call, because owning the dialog is the price of owning the media.
|
||||
|
||||
Alternatives considered and rejected:
|
||||
|
||||
- **Terminate RTP ourselves** (aiortc or raw sockets) and feed PCM into
|
||||
`AudioTap` directly, keeping Sippy on the wire. Preserves the split, but
|
||||
means owning jitter buffering, packet loss concealment and ulaw/alaw
|
||||
transcoding — precisely the work PJSUA2 exists to do.
|
||||
- **A loopback `pj.Call` mirroring each real leg**, so PJSUA2 has a dialog it
|
||||
owns. Avoids touching call placement, but adds a phantom call per real call
|
||||
and the SDP juggling is fragile.
|
||||
|
||||
> **Safety note for this refactor:** `is_emergency_number()` stays the first
|
||||
> check in `gateway.make_call`, above the concurrency cap and above any SIP
|
||||
> action, regardless of which library dials. A new outbound path that reaches
|
||||
> the SIP layer without passing that guard is a serious regression even if
|
||||
> every test passes.
|
||||
|
||||
### Why asyncio Queue-based EventBus?
|
||||
|
||||
@@ -164,11 +231,13 @@ This split lets us tap into the audio stream (for classification and STT) withou
|
||||
- **Dead subscriber cleanup** — full queues are automatically removed
|
||||
- **Event history** — late joiners can catch up on recent events
|
||||
|
||||
If scaling to multiple gateway processes becomes necessary, the EventBus interface can be backed by Redis pub/sub without changing consumers.
|
||||
If scaling to multiple gateway processes becomes necessary, the EventBus
|
||||
interface can be backed by Redis pub/sub without changing consumers.
|
||||
|
||||
### Why OpenAI-compatible LLM API?
|
||||
|
||||
The LLM client uses raw HTTP (httpx) against any OpenAI-compatible endpoint. This means:
|
||||
The LLM client uses raw HTTP (httpx) against any OpenAI-compatible endpoint.
|
||||
This means:
|
||||
|
||||
- **Ollama** (local, free) — `http://localhost:11434/v1`
|
||||
- **LM Studio** (local, free) — `http://localhost:1234/v1`
|
||||
@@ -176,3 +245,11 @@ The LLM client uses raw HTTP (httpx) against any OpenAI-compatible endpoint. Thi
|
||||
- **OpenAI** (cloud) — `https://api.openai.com/v1`
|
||||
|
||||
No SDK dependency. No vendor lock-in. Switch models by changing one env var.
|
||||
|
||||
## Testing against a fake PSTN
|
||||
|
||||
`tests/lab/` runs 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. `SIP_TRUNK_HOST` is just an address, so the production
|
||||
code path runs unmodified; while it points at the lab there is no route to the
|
||||
PSTN at all. See [tests/lab/README.md](../tests/lab/README.md).
|
||||
|
||||
293
docs/asterisk-lab-design.md
Normal file
293
docs/asterisk-lab-design.md
Normal file
@@ -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.)
|
||||
@@ -4,6 +4,25 @@ All configuration is via environment variables, loaded through Pydantic Settings
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Auth (Casdoor SSO + owner)
|
||||
|
||||
The gateway is **owner-only**: the browser signs in via Casdoor (JWT), MCP/CLI
|
||||
clients use owner-minted PATs, and only `OWNER_NAME` may use any surface. With
|
||||
`CASDOOR_ENABLED=false` the gateway runs in dev-owner mode — permitted **only** on
|
||||
a loopback `HOST`. Startup refuses SSO-enabled-with-missing-config and
|
||||
SSO-disabled-off-loopback.
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `CASDOOR_ENABLED` | Enable Casdoor SSO | `false` | No |
|
||||
| `CASDOOR_ENDPOINT` | Casdoor base URL | `https://id.ouranos.helu.ca` | If SSO on |
|
||||
| `CASDOOR_CLIENT_ID` | Casdoor application client ID | — | If SSO on |
|
||||
| `CASDOOR_CLIENT_SECRET` | Casdoor application client secret | — | If SSO on |
|
||||
| `CASDOOR_ORG_NAME` | Casdoor organization | `heluca` | No |
|
||||
| `CASDOOR_APP_NAME` | Casdoor application name | — | No |
|
||||
| `OWNER_NAME` | Casdoor username of the single operator | — | If SSO on |
|
||||
| `PUBLIC_BASE_URL` | Public base URL for OAuth discovery (else derived) | — | No |
|
||||
|
||||
### SIP Trunk
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
@@ -15,14 +34,36 @@ All configuration is via environment variables, loaded through Pydantic Settings
|
||||
| `SIP_TRUNK_DID` | Your phone number (E.164) | — | Yes |
|
||||
| `SIP_TRUNK_TRANSPORT` | Transport protocol (`udp`, `tcp`, `tls`) | `udp` | No |
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `HOST` | Bind address. Off-loopback requires `CASDOOR_ENABLED=true` | `0.0.0.0` | No |
|
||||
| `PORT` | Bind port | `8000` | No |
|
||||
| `DEBUG` | SQLAlchemy echo + uvicorn reload | `false` | No |
|
||||
| `LOG_LEVEL` | Root log level (`debug`/`info`/`warning`/`error`) | `info` | No |
|
||||
| `LOG_FORMAT` | `text` (human-readable) or `json` (structured, for Loki) | `text` | No |
|
||||
|
||||
`LOG_FORMAT=json` renders one JSON object per line, including uvicorn's access
|
||||
log — `method`, `path`, `status_code` (numeric, so it can be range-filtered) and
|
||||
`client_addr` arrive as queryable fields rather than a formatted string. The
|
||||
Docker image sets it; `text` is the default so local development stays readable.
|
||||
|
||||
### Safety
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `MAX_CONCURRENT_CALLS` | Cap on simultaneous outbound calls | `4` | No |
|
||||
| `USE_MOCK_SIP` | Run the mock SIP engine — no real calls. Must be asked for explicitly; an unconfigured trunk without it fails startup | `false` | No |
|
||||
| `SIP_ENGINE` | `sippy` (signalling only — no audio reaches the classifier) or `pjsua2` (call control + media) | `sippy` | No |
|
||||
|
||||
### Gateway
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `GATEWAY_SIP_PORT` | Port for device SIP registration | `5080` | No |
|
||||
| `GATEWAY_RTP_PORT_MIN` | Minimum RTP port | `10000` | No |
|
||||
| `GATEWAY_RTP_PORT_MAX` | Maximum RTP port | `20000` | No |
|
||||
| `GATEWAY_HOST` | Bind address | `0.0.0.0` | No |
|
||||
| `GATEWAY_SIP_HOST` | Bind address for the device-registration listener | `0.0.0.0` | No |
|
||||
| `GATEWAY_SIP_PORT` | Port for device SIP registration | `5060` | No |
|
||||
| `GATEWAY_SIP_DOMAIN` | SIP domain devices register against | `gateway.local` | No |
|
||||
|
||||
### LLM
|
||||
|
||||
@@ -46,7 +87,7 @@ All configuration is via environment variables, loaded through Pydantic Settings
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `DATABASE_URL` | PostgreSQL or SQLite connection string | `sqlite+aiosqlite:///./hold_slayer.db` | No |
|
||||
| `DATABASE_URL` | PostgreSQL connection string. Startup exits with a readable error if unset | — | Yes |
|
||||
|
||||
### Notifications
|
||||
|
||||
@@ -54,6 +95,18 @@ All configuration is via environment variables, loaded through Pydantic Settings
|
||||
|----------|-------------|---------|----------|
|
||||
| `NOTIFY_SMS_NUMBER` | Phone number for SMS alerts (E.164) | — | No |
|
||||
|
||||
### Receptionist
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `RECEPTIONIST_ENABLED` | Answer inbound calls with the AI receptionist | `true` | No |
|
||||
| `RECEPTIONIST_GREETING_TEMPLATE` | Spoken greeting | `"Hi, you've reached Robert's line. Who's calling, and what's this about?"` | No |
|
||||
| `RECEPTIONIST_MESSAGE_PROMPT` | Spoken prompt before recording a message | `"Please leave your message after the tone."` | No |
|
||||
| `RECEPTIONIST_LLM_PERSONA` | System prompt shaping the receptionist's decisions | See `config.py` | No |
|
||||
| `RECEPTIONIST_LISTEN_TIMEOUT_S` | Seconds to wait for the caller to speak | `15.0` | No |
|
||||
| `RECEPTIONIST_END_OF_UTTERANCE_SILENCE_S` | Silence marking the end of a turn | `1.2` | No |
|
||||
| `RECEPTIONIST_MESSAGE_MAX_SECONDS` | Voicemail cap | `90` | No |
|
||||
|
||||
### Audio Classifier
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|
||||
343
docs/deployment-validation-plan.md
Normal file
343
docs/deployment-validation-plan.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# Hold Slayer — Deployment & Validation Plan
|
||||
|
||||
Bring the gateway up in stages, proving each layer before the one above it can
|
||||
lie about working. The ordering principle: **nothing touches the PSTN until
|
||||
everything that can be validated without it has been.** A bug found on the trunk
|
||||
costs money and rings a stranger's phone; the same bug found internally costs
|
||||
nothing.
|
||||
|
||||
Phases gate on each other. Do not start a phase until its predecessor's exit
|
||||
criteria are all green.
|
||||
|
||||
---
|
||||
|
||||
## Environment as found (2026-07-28)
|
||||
|
||||
Verified on `caliban`, not assumed:
|
||||
|
||||
| Fact | State | Consequence |
|
||||
|---|---|---|
|
||||
| `sippy` 2.3.0 | installed | SIP signaling is real |
|
||||
| `pjsua2` | ✅ **built 2026-07-28** (pjproject 2.17) | real media pipeline; see [pjsua2-build.md](pjsua2-build.md) |
|
||||
| `USE_MOCK_SIP` | `true` | engine is `MockSIPEngine`; `/health` can never be `healthy` |
|
||||
| `SIP_TRUNK_*` | placeholders (`sip.yourprovider.com`) | no trunk configured |
|
||||
| `SIP_TRUNK_DID` | `+16472474242` | real DID already allocated |
|
||||
| `GATEWAY_SIP_PORT` | `5060` | `.env.example` says 5080 — reconcile |
|
||||
| `DATABASE_URL` | `portia.incus:5432/hold_slayer` | remote Postgres, reachable? unverified |
|
||||
| Casdoor | enabled, `hold-slayer` app, `OWNER_NAME=r@helu.ca` | SSO path is configured |
|
||||
| `SPEACHES_URL` | `pan.helu.ca:22070` | not reachable from this host as tested |
|
||||
| `LLM_BASE_URL` | `nyx.helu.ca:29000` | not reachable from this host as tested |
|
||||
| `TTS_*` | unset → defaults to `localhost:8000` | **collides with the app's own port** — must be set |
|
||||
| `pytest` | 159 pass, 6 fail | 4 are `.env` bleed, 2 are real (see Phase 0) |
|
||||
|
||||
Two config landmines worth fixing before anything else: `TTS_BASE_URL` is unset
|
||||
and defaults to `http://localhost:8000`, which is Hold Slayer's own port — TTS
|
||||
calls would loop back into the gateway. And `API_TOKEN` still sits in `.env`
|
||||
though the code now uses Casdoor + PATs; it is dead weight that suggests the old
|
||||
auth path still exists.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Baseline: make the test suite an honest gate ✅ COMPLETE
|
||||
|
||||
**Done 2026-07-28.** Suite is 165 passed / 0 failed from the repo root with the
|
||||
real `.env` present. Outcomes differed from the plan's guesses in one important
|
||||
way: the classifier failure was a **real bug in `classify_chunk`**, not a
|
||||
threshold or fixture problem — see item 3. One item is deferred with rationale
|
||||
(`ruff`, below).
|
||||
|
||||
You cannot use a suite with known failures as a regression gate; every later
|
||||
phase's "did I break something" check depends on a clean baseline.
|
||||
|
||||
**Do:**
|
||||
1. Fix the `.env` bleed in `tests/test_oauth_metadata.py`. The 4 failures are
|
||||
entirely because pydantic reads the real `.env` and `PUBLIC_BASE_URL=http://localhost:21081`
|
||||
overrides the test's expected `http://test`. Confirmed: the same tests pass
|
||||
when run from a directory without `.env`. Fix by monkeypatching
|
||||
`get_settings().public_base_url = ""` in the `client` fixture, so tests derive
|
||||
the base URL from request headers as intended.
|
||||
2. Resolve `tests/test_hold_slayer.py::TestMockSIPEngine::test_trunk_status`.
|
||||
**The test is wrong, not the code.** It asserts the mock trunk reports
|
||||
`registered is True` after `start()`, but `MockSIPEngine.get_trunk_status()`
|
||||
deliberately returns `registered: False` with
|
||||
`reason: "No SIP trunk configured (mock mode)"` — which is exactly what the
|
||||
`/health`-tells-the-truth invariant requires. Update the test to assert
|
||||
`False` + the reason.
|
||||
3. Triage `test_audio_classifier.py::test_complex_tone_as_music`. **Outcome: the
|
||||
test was right and the code had a real bug.** Measured feature values for the
|
||||
C-major chord: `music_score` = **0.850**, comfortably above the 0.7
|
||||
`music_threshold` — music was detected confidently. But step 5's
|
||||
`if speech_score > music_score` ran first and short-circuited, because
|
||||
`speech_score` = **1.000**: the four speech bands are wide and overlapping
|
||||
(flatness 0.223 ∈ (0.1,0.5), centroid 852 ∈ (500,4000), ZCR 0.039 ∈
|
||||
(0.02,0.2), RMS 0.183 ∈ (0.01,0.5)) and a sustained musical chord satisfies
|
||||
all four trivially. Verified structural, not a one-off — C major, A minor and
|
||||
a bright chord *all* classified `live_human` despite scoring 0.85/0.65 music.
|
||||
|
||||
Fixed in `services/audio_classifier.py` by making the speech branch yield to a
|
||||
confident music score:
|
||||
`if speech_score > music_score and music_score < self.settings.music_threshold:`
|
||||
One line, both thresholds stay meaningful, scorers untouched. Chords now
|
||||
classify `music` (0.85) and all 18 classifier tests pass, so speech-like audio
|
||||
still classifies as speech.
|
||||
|
||||
**This was the plan's highest-value find:** the bug is exactly the operational
|
||||
failure that rings your desk for a hold queue. Worth re-validating against
|
||||
*real* hold music in Phase 2 — the synthetic "bright chord" case scores 0.65,
|
||||
below threshold, so it still reads as speech.
|
||||
|
||||
**Exit criteria:** `pytest tests/ -v` green from the repo root with the real
|
||||
`.env` present ✅ (165 passed). `ruff check .` clean — **deferred, see below**.
|
||||
|
||||
**Deferred: `ruff check .` reports 217 errors — all pre-existing.** Verified
|
||||
identical (217) with my changes stashed, so nothing here was introduced by
|
||||
Phase 0, and the three files I touched add none. The backlog is stylistic and
|
||||
repo-wide: 157 × `UP045` (`Optional[X]` → `X | None`), 18 × `F401` unused
|
||||
imports, 14 × `E501`, spread across `services/` (77), `models/` (75) and
|
||||
`core/` (49). Auto-fixing would rewrite annotations throughout `sippy_engine.py`
|
||||
and `media_pipeline.py` — the thread-boundary and media code that currently
|
||||
**cannot be exercised** (no PJSUA2, no trunk). That is a large unverifiable diff
|
||||
in the highest-risk files, unrelated to making the suite an honest gate. Recommend
|
||||
its own commit, ideally after Phase 1b restores the ability to actually run that
|
||||
code.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1a — Internal signaling with a registered softphone (no audio)
|
||||
|
||||
Prove the SIP stack, registration, dial plan, and call lifecycle with zero
|
||||
media and zero PSTN. This is the phase that de-risks the most for the least.
|
||||
|
||||
**Config:**
|
||||
```
|
||||
USE_MOCK_SIP=false # real Sippy engine
|
||||
SIP_TRUNK_HOST= # blank — engine skips _register_trunk()
|
||||
GATEWAY_SIP_HOST=0.0.0.0
|
||||
GATEWAY_SIP_PORT=5060
|
||||
GATEWAY_SIP_DOMAIN=voip.helu.ca
|
||||
```
|
||||
Leaving `SIP_TRUNK_HOST` blank is deliberate and load-bearing: `SippyEngine.start()`
|
||||
only calls `_register_trunk()` `if self._trunk_host`, so the gateway comes up
|
||||
listening for devices with **no possible path to the PSTN**. That is the
|
||||
strongest safety guarantee available in this phase — not policy, but absence of
|
||||
a route.
|
||||
|
||||
**Setup:**
|
||||
1. Register a softphone (Linphone or Zoiper on a laptop; Groundwire on mobile)
|
||||
to `<caliban-ip>:5060`, any username, domain `voip.helu.ca`.
|
||||
**Expect registration to succeed with any credentials** — see the security
|
||||
finding below.
|
||||
2. Confirm the device appears: `GET /api/v1/devices` and the `list_devices` MCP
|
||||
tool, plus the `📱 SIP REGISTER` line in the log.
|
||||
3. Create a `Device` row for it (`type: sip_phone`, `sip_uri` matching the
|
||||
registered contact) so `call_device()` can target it.
|
||||
|
||||
**Validate:**
|
||||
- REGISTER lands, appears in `_registered_devices`, refreshes on re-REGISTER,
|
||||
and disappears on `expires=0` (hang up / unregister in the softphone).
|
||||
- Inbound INVITE from the softphone to the gateway surfaces an `incoming_invite`
|
||||
event and routes through the receptionist path.
|
||||
- `gateway.call_device()` toward the softphone makes it **ring** — this
|
||||
exercises `_call_sip_device`, SDP generation, and the loop→Sippy funnel.
|
||||
- Answer, then hang up from each end in turn; confirm `terminated` propagates
|
||||
and the leg is cleaned from `_legs`.
|
||||
- DTMF from the softphone appears in the log (note: received DTMF currently has
|
||||
no consumer — it logs and stops).
|
||||
- `/health` reports `degraded` with `sip_trunk.registered: false`. **This is
|
||||
correct** — do not chase a green light here.
|
||||
|
||||
**Security finding to address in this phase:** `_handle_incoming_register()`
|
||||
replies `200 OK` to any REGISTER with no authentication challenge — no 401 with
|
||||
a nonce, no digest verification. Any host that can reach port 5060 can register
|
||||
as any AOR and become a transfer target. On a LAN-only bind for this phase
|
||||
that's tolerable; **before Phase 3 exposes the gateway to a trunk it is not.**
|
||||
Either add digest auth to the REGISTER path or firewall 5060 to known device
|
||||
IPs. Flagging rather than folding in — it's a real change to the SIP path and
|
||||
your call how to scope it.
|
||||
|
||||
**Exit criteria:** softphone registers, rings on `call_device`, both-direction
|
||||
teardown is clean, no leaked legs after 10 call cycles, suite still green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1b — Gate: build PJSUA2 ✅ COMPLETE
|
||||
|
||||
**Done 2026-07-28.** Built out of order (ahead of Phase 1a) because nothing
|
||||
useful works without it. pjproject **2.17** — newer than expected, with Python
|
||||
3.13 and modern-gcc support already upstream, so no patching was needed.
|
||||
|
||||
`MediaPipeline.status()` now reports **`pjsua2_available: True`**; the stub-mode
|
||||
warning is gone and the pipeline starts and stops cleanly at 16 kHz. Full
|
||||
procedure recorded in **[pjsua2-build.md](pjsua2-build.md)**; README note now
|
||||
points at it.
|
||||
|
||||
Notes worth carrying forward:
|
||||
|
||||
- **No `sudo` was needed.** Both missing tools (`swig`, `patchelf`) have PyPI
|
||||
wheels and installed into the venv, so nothing on the host changed outside
|
||||
`~/src` and `~/.local`.
|
||||
- **The RPATH step is the non-obvious part.** The bindings compile and install
|
||||
cleanly and then fail at import with
|
||||
`ImportError: libpjsua2.so.2: cannot open shared object file`, because
|
||||
`~/.local/lib` isn't on the loader path. Fixed with `patchelf --set-rpath` on
|
||||
both the extension **and** all 12 `libpj*.so.2` libraries — patching only the
|
||||
extension just surfaces the transitive deps one layer down. Chosen over
|
||||
`LD_LIBRARY_PATH` because that would have to be set for uvicorn, systemd, cron
|
||||
and every subprocess, and it fails at call time rather than startup. Verified
|
||||
under `env -i` from `/`, so it depends on no inherited environment.
|
||||
- **Configure found OpenSSL, ALSA and Opus** — the full codec/TLS surface.
|
||||
- **165 tests still pass.** All `pjsua2` imports in the codebase are lazy
|
||||
(inside functions), so the suite still runs without touching real media.
|
||||
|
||||
**Exit criteria:** `import pjsua2` succeeds in the venv ✅; `MediaPipeline`
|
||||
reports `pjsua2_available: true` ✅ (note the method is `status()`, not
|
||||
`get_status()` as this plan originally said).
|
||||
|
||||
**Two caveats this build does not solve:**
|
||||
|
||||
1. **Not captured by `pip install -e ".[dev]"`.** It lives outside Python
|
||||
packaging metadata — a fresh venv, new host or rebuilt container needs it
|
||||
repeated. Host provisioning, not a dependency.
|
||||
2. **The Docker image still runs stub media** — the Dockerfile deliberately
|
||||
skips this build. So the Phase 2 audio validation must run **outside the
|
||||
container**, or the Dockerfile needs a pjproject build stage. Worth deciding
|
||||
before Phase 2, since it determines where you test.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Audio, TTS, STT, and the classifier on internal calls
|
||||
|
||||
Now the softphone is a full test instrument: you can speak into the gateway and
|
||||
hear it speak back, with no telephony charges.
|
||||
|
||||
**Config to fix first:**
|
||||
```
|
||||
TTS_BASE_URL=<real Rhema endpoint> # NOT localhost:8000 — that's this app
|
||||
TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
|
||||
SPEACHES_URL=http://pan.helu.ca:22070
|
||||
```
|
||||
|
||||
**Validate leaf services standalone before wiring them into a call** — a failure
|
||||
here is much easier to read outside the call path:
|
||||
- `TTSService.synthesize()` returns PCM directly (a short script against the
|
||||
service). Confirm `service.available` flips to `True` and `/health` reports
|
||||
`tts: ok`.
|
||||
- `TranscriptionService.transcribe()` against a known WAV returns expected text;
|
||||
`/health` reports `stt: ok`.
|
||||
- Both are reachable **from wherever the gateway actually runs** — neither
|
||||
`pan.helu.ca:22070` nor `nyx.helu.ca:29000` answered from this host during
|
||||
assessment. Resolve that before blaming the call path.
|
||||
- LLM: `nyx.helu.ca:29000` with `Qwen3.6-35B-A3B-UD-Q4_K_XL`. Note the URL has
|
||||
no `/v1` suffix while the default does — verify which the client expects.
|
||||
|
||||
**Then in-call, softphone ↔ gateway only:**
|
||||
- Receptionist answers an inbound call from the softphone: you hear the TTS
|
||||
greeting, speak, and your speech is transcribed. This single test exercises
|
||||
TTS + STT + LLM + media in the real path.
|
||||
- Recording writes a real file to `recordings/` with actual audio in it (check
|
||||
the file plays, not just that it exists — the stub path created files too).
|
||||
- Classifier: play hold music from a phone/laptop into the call and confirm
|
||||
`MUSIC`; speak and confirm `LIVE_HUMAN`. This is the ground truth that
|
||||
Phase 0's failing classifier test was gesturing at.
|
||||
- Transcript persists to the DB on hangup and is readable via
|
||||
`get_call_transcript`.
|
||||
|
||||
**Exit criteria:** a full receptionist conversation over the softphone with
|
||||
audible TTS, accurate STT, correct classification, a playable recording, and a
|
||||
persisted transcript.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — MCP integration
|
||||
|
||||
Independent of audio, so it can run in parallel with 1b/2 if convenient.
|
||||
|
||||
- Mint a PAT via `api/tokens.py`, confirm `hs_pat_…` format.
|
||||
- Connect an MCP client to `https://<host>/mcp/` and confirm OAuth discovery
|
||||
(`/.well-known/oauth-protected-resource`) resolves against the real
|
||||
`PUBLIC_BASE_URL`.
|
||||
- Read-only tools first: `gateway_status`, `list_active_calls`, `list_devices`,
|
||||
`search_call_history`, and the three resources.
|
||||
- **Verify the auth boundary negatively**: a non-owner identity and a bogus PAT
|
||||
both get 403/401 on `/mcp/`, REST, and WS. Owner-only is an invariant; prove
|
||||
it rather than assuming it.
|
||||
- `make_call` against the softphone (internal, no trunk) — confirms the MCP
|
||||
path reaches the gateway.
|
||||
- **Test the emergency guard through MCP specifically**: `make_call("911")`,
|
||||
`"9911"`, `"112"`, `"+1911"`, and with whitespace/dashes must every one raise
|
||||
`ToolError` before any SIP action. Do this while `SIP_TRUNK_HOST` is still
|
||||
blank, so a guard failure cannot become an actual emergency call. **This is
|
||||
the single most important test in the plan** — run it before Phase 4, never
|
||||
after.
|
||||
|
||||
**Exit criteria:** all read tools return sane data, `make_call` rings the
|
||||
softphone, every emergency variant is refused, non-owner is refused.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — SIP trunk (first PSTN contact)
|
||||
|
||||
Only now does real money and a real network get involved.
|
||||
|
||||
1. Set `SIP_TRUNK_HOST/PORT/USERNAME/PASSWORD/TRANSPORT` for the provider; keep
|
||||
`SIP_TRUNK_DID=+16472474242`.
|
||||
2. Restart and watch registration: `_register_trunk()` posts `trunk_registered`,
|
||||
`/health` should flip to **`healthy`** — real engine + registered trunk +
|
||||
reachable DB. This is the first moment a green `/health` is meaningful.
|
||||
3. **Re-run the entire emergency-guard suite from Phase 3** now that a real
|
||||
route exists. The guard is what stands between an AI agent and a 911
|
||||
dispatcher with no E911 location attached.
|
||||
4. First outbound call: dial **your own mobile**, nothing else. Confirm ring,
|
||||
answer, two-way audio, clean teardown, DB persistence.
|
||||
5. First inbound: call the DID from your mobile, receptionist answers, routing
|
||||
and transfer-to-softphone work.
|
||||
6. Then a real hold scenario against a known IVR with a long queue.
|
||||
|
||||
**Safety rails while validating:**
|
||||
- Drop `MAX_CONCURRENT_CALLS` to `1` for first calls; restore to 4 after.
|
||||
- Keep `MAX_HOLD_TIME` low initially — 7200s is two hours of trunk time if
|
||||
something wedges.
|
||||
- Watch the provider's billing/CDR page live during the first calls.
|
||||
- Have the provider portal open to kill calls out-of-band.
|
||||
|
||||
**Exit criteria:** `/health` genuinely `healthy`, one successful outbound to a
|
||||
known number, one inbound handled, CDRs match expectations, no orphaned legs.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting: `.env.example` drift ✅ FIXED (Phase 0)
|
||||
|
||||
`.env.example` had documented the removed `API_TOKEN` as the auth mechanism and
|
||||
omitted every `CASDOOR_*` var, `OWNER_NAME`, `PUBLIC_BASE_URL`, and the whole
|
||||
`TTS_*` block. Rewritten to match `config.py`: dropped `API_TOKEN`, added the
|
||||
Casdoor/auth block (documenting both supported configurations and why SSO-off
|
||||
off-loopback is refused), the full `TTS_*` and `RECEPTIONIST_*` blocks, and
|
||||
reconciled `GATEWAY_SIP_PORT` to 5060 to match `.env`.
|
||||
|
||||
`TTS_BASE_URL` now defaults to `localhost:8001` in the template with a comment
|
||||
warning that it must not equal the app's own `PORT` — this was blocker #3, and
|
||||
the template previously would have reproduced it for any fresh checkout.
|
||||
|
||||
Validated by copying the template to a clean directory and parsing it through
|
||||
`Settings()`: every sub-config loads, and `tts.base_url != localhost:{port}` is
|
||||
asserted. `grep` confirms no `API_TOKEN` references remain in code, templates or
|
||||
docs. The README config table was already current — no drift there.
|
||||
|
||||
---
|
||||
|
||||
## Summary of blockers found
|
||||
|
||||
| # | Blocker | Blocks | Severity |
|
||||
|---|---|---|---|
|
||||
| 1 | PJSUA2 not installed | all audio: TTS/STT/recording/classifier in-call | ✅ **fixed** — pjproject 2.17 built, `pjsua2_available: True` |
|
||||
| 2 | REGISTER accepts any credentials, no digest auth | safe exposure of port 5060 | **security** |
|
||||
| 3 | `TTS_BASE_URL` unset → defaults to app's own port | TTS entirely | config — ✅ fixed in template; **still set it in your real `.env`** |
|
||||
| 4 | STT/LLM endpoints unreachable as tested | Phase 2 | environment |
|
||||
| 5 | 6 failing tests (2 real, 4 `.env` bleed) | honest regression gate | ✅ **fixed** — 165 pass |
|
||||
| 6 | `.env.example` documents removed `API_TOKEN` auth | fresh-environment test | ✅ **fixed** |
|
||||
| 7 | Hold music classified as `LIVE_HUMAN` (found during Phase 0) | correct hold detection | ✅ **fixed** — real bug, see Phase 0 item 3 |
|
||||
|
||||
**Note on #3:** the fix landed in `.env.example` (the template). Your live
|
||||
`.env` still has no `TTS_*` block at all, so TTS resolves to the
|
||||
`localhost:8000` default and collides with the app. Set `TTS_BASE_URL` before
|
||||
Phase 2.
|
||||
@@ -3,8 +3,11 @@
|
||||
The MCP (Model Context Protocol) server lets any MCP-compatible AI assistant
|
||||
control the Hold Slayer gateway. Built with [FastMCP](https://github.com/jlowin/fastmcp),
|
||||
it is mounted on the FastAPI app at **`/mcp/`** (trailing slash) over
|
||||
**streamable HTTP** and authenticates with the same static bearer token as the
|
||||
REST API and WebSocket.
|
||||
**streamable HTTP** and authenticates with an owner-minted Personal Access Token
|
||||
(`hs_pat_…`) — the same owner-only auth as the REST API and WebSocket. Auth is
|
||||
enforced by an ASGI guard (`_owner_only_mcp` in `main.py`) that resolves the
|
||||
bearer to the owner; a Casdoor JWT also works, but MCP clients can't refresh one,
|
||||
so a PAT is the intended credential.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -158,7 +161,7 @@ Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add hold-slayer --transport http http://localhost:8000/mcp/ \
|
||||
--header "Authorization: Bearer $API_TOKEN"
|
||||
--header "Authorization: Bearer hs_pat_..."
|
||||
```
|
||||
|
||||
Generic MCP client configuration:
|
||||
@@ -168,7 +171,7 @@ Generic MCP client configuration:
|
||||
"mcpServers": {
|
||||
"hold-slayer": {
|
||||
"url": "http://localhost:8000/mcp/",
|
||||
"headers": {"Authorization": "Bearer <API_TOKEN>"}
|
||||
"headers": {"Authorization": "Bearer hs_pat_..."}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
130
docs/pjsua2-build.md
Normal file
130
docs/pjsua2-build.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Building the PJSUA2 Python bindings
|
||||
|
||||
The media pipeline ([core/media_pipeline.py](../core/media_pipeline.py)) needs
|
||||
the `pjsua2` Python bindings. They are **not pip-installable** — they are SWIG
|
||||
bindings compiled from pjproject. Without them `MediaPipeline.start()` catches
|
||||
`ImportError` and runs in **stub mode**: signaling works, but audio routing,
|
||||
recording, tapping and playback are all no-ops that *return successfully*. That
|
||||
last part is the trap — a stub gateway looks healthy while being unable to speak
|
||||
or listen.
|
||||
|
||||
Verified on Ubuntu 25.10 / Python 3.13.7 / gcc 15.2, pjproject **2.17**,
|
||||
2026-07-28.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
`swig` and `patchelf` are both needed and both have PyPI wheels, so **no `sudo`
|
||||
is required** — install them into the venv:
|
||||
|
||||
```bash
|
||||
pip install swig patchelf
|
||||
```
|
||||
|
||||
System dev libraries (already present on caliban; `libsrtp2-dev` is *not*
|
||||
needed — pjproject bundles its own SRTP):
|
||||
|
||||
```
|
||||
libasound2-dev libssl-dev libopus-dev uuid-dev python3-dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
mkdir -p ~/src && cd ~/src
|
||||
git clone --depth 1 --branch 2.17 https://github.com/pjsip/pjproject.git
|
||||
cd pjproject
|
||||
|
||||
# Minimal config_site.h — enable TLS transport support
|
||||
echo '#define PJ_HAS_SSL_SOCK 1' > pjlib/include/pj/config_site.h
|
||||
|
||||
# -fPIC is REQUIRED: the Python extension links these into a shared object.
|
||||
# --enable-shared builds the .so files the bindings load at runtime.
|
||||
CFLAGS="-fPIC -O2" CXXFLAGS="-fPIC -O2" ./configure \
|
||||
--enable-shared \
|
||||
--disable-video --disable-libyuv --disable-libwebrtc \
|
||||
--prefix=$HOME/.local
|
||||
|
||||
make dep && make -j$(nproc) && make install
|
||||
```
|
||||
|
||||
Confirm configure found what the gateway needs (all should say yes/enabled):
|
||||
OpenSSL, ALSA (`alsa/version.h`), OPUS.
|
||||
|
||||
```bash
|
||||
# Python bindings
|
||||
cd pjsip-apps/src/swig
|
||||
make python
|
||||
cd python && python setup.py install
|
||||
```
|
||||
|
||||
## The RPATH step — do not skip this
|
||||
|
||||
The shared libraries install to `~/.local/lib`, which is **not** on the default
|
||||
loader path, and neither the extension nor pjproject's own libraries carry an
|
||||
RPATH. Straight after `setup.py install` the import fails with:
|
||||
|
||||
```
|
||||
ImportError: libpjsua2.so.2: cannot open shared object file
|
||||
```
|
||||
|
||||
Rather than requiring `LD_LIBRARY_PATH` everywhere (it would have to be set for
|
||||
`uvicorn`, systemd, cron and any subprocess — easy to miss, and it fails at call
|
||||
time, not startup), bake the path into the binaries:
|
||||
|
||||
```bash
|
||||
# The extension module …
|
||||
patchelf --set-rpath $HOME/.local/lib \
|
||||
$VIRTUAL_ENV/lib/python3.13/site-packages/_pjsua2.cpython-313-x86_64-linux-gnu.so
|
||||
|
||||
# … and pjproject's libraries, which must also find each other.
|
||||
cd ~/.local/lib && for f in libpj*.so.2; do
|
||||
patchelf --set-rpath $HOME/.local/lib "$f"
|
||||
done
|
||||
```
|
||||
|
||||
Patching only the extension is not enough: it resolves `libpjsua2`, which then
|
||||
fails on its own transitive deps (`libpjsua`, `libpjsip`, `libpjmedia`, `libpj`,
|
||||
…). Patch the whole set.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# 1. Loads with a completely empty environment (proves RPATH, not inherited vars)
|
||||
cd / && env -i $VIRTUAL_ENV/bin/python -c \
|
||||
"import pjsua2 as pj; ep=pj.Endpoint(); ep.libCreate(); print(ep.libVersion().full); ep.libDestroy()"
|
||||
|
||||
# 2. No unresolved libraries
|
||||
ldd $VIRTUAL_ENV/lib/python3.13/site-packages/_pjsua2*.so | grep "not found"
|
||||
|
||||
# 3. The real gate — Hold Slayer's own pipeline reports it
|
||||
python -c "
|
||||
import asyncio
|
||||
from core.media_pipeline import MediaPipeline
|
||||
async def m():
|
||||
p = MediaPipeline(); await p.start()
|
||||
assert p.status()['pjsua2_available'] is True
|
||||
print('pjsua2_available: True'); await p.stop()
|
||||
asyncio.run(m())"
|
||||
```
|
||||
|
||||
`ImportError` in step 1 or `pjsua2_available: False` in step 3 means you are
|
||||
still in stub mode.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Not captured by `pip install -e ".[dev]"`.** This build lives outside the
|
||||
Python packaging metadata, so a fresh venv, a rebuilt container, or another
|
||||
host needs it repeated. Treat it as host provisioning.
|
||||
- **The Docker image deliberately does not build this** (see the comment at the
|
||||
top of the [Dockerfile](../Dockerfile)) — the container therefore runs stub
|
||||
media. Anything validating audio must run outside the image, or the Dockerfile
|
||||
needs a build stage adding.
|
||||
- **Threading:** PJSUA2 starts its own worker threads, in addition to the Sippy
|
||||
ED thread. Per the concurrency rule, PJSUA2 objects belong to the media
|
||||
pipeline and must not be touched from the Sippy thread or mutated directly
|
||||
from the asyncio loop.
|
||||
- The extension compiles against system headers (`/usr/include/python3.13`)
|
||||
rather than the venv's. Harmless while both are the same 3.13.7 with matching
|
||||
SOABI — worth re-checking if the venv's Python is ever upgraded independently.
|
||||
299
main.py
299
main.py
@@ -12,17 +12,22 @@ Usage:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from api import call_flows, call_history, calls, devices, routing, websocket
|
||||
from api.deps import require_token
|
||||
from api import auth as auth_router
|
||||
from api import call_flows, call_history, calls, devices, routing, tokens, websocket
|
||||
from auth import get_current_owner, init_jwks_client, is_owner, resolve_from_header_or_query
|
||||
from config import Settings, get_settings
|
||||
from core.gateway import AIPSTNGateway, build_sip_engine
|
||||
from db.database import close_db, init_db
|
||||
from core.logging_config import configure_logging
|
||||
from db.database import close_db, init_db, session_scope
|
||||
from mcp_server.server import create_mcp_server
|
||||
from models.call import CallMode
|
||||
from services.audio_classifier import AudioClassifier
|
||||
@@ -35,13 +40,12 @@ from services.routing import RoutingService
|
||||
from services.transcription import TranscriptionService
|
||||
from services.tts import TTSService
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
# Configure logging at import so anything logged during module import and
|
||||
# startup config checks is formatted. Uvicorn installs its own handlers *after*
|
||||
# importing this module, so `lifespan` calls `configure_logging` again to take
|
||||
# them over — see core/logging_config.py.
|
||||
_startup_settings = get_settings()
|
||||
configure_logging(_startup_settings.log_format, _startup_settings.log_level)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -106,16 +110,38 @@ def _check_startup_config(settings: Settings) -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
token = settings.api_token.get_secret_value()
|
||||
if not token and settings.host not in ("127.0.0.1", "localhost", "::1"):
|
||||
loopback = settings.host in ("127.0.0.1", "localhost", "::1")
|
||||
if settings.casdoor.enabled:
|
||||
c = settings.casdoor
|
||||
missing = [
|
||||
name
|
||||
for name, val in (
|
||||
("CASDOOR_ENDPOINT", c.endpoint),
|
||||
("CASDOOR_CLIENT_ID", c.client_id),
|
||||
("CASDOOR_CLIENT_SECRET", c.client_secret.get_secret_value()),
|
||||
("OWNER_NAME", settings.owner_name),
|
||||
)
|
||||
if not val
|
||||
]
|
||||
if missing:
|
||||
logger.critical(
|
||||
"\n"
|
||||
"❌ API_TOKEN is not set but HOST binds beyond loopback "
|
||||
"❌ CASDOOR_ENABLED=true but required settings are missing:\n"
|
||||
f" {', '.join(missing)}\n"
|
||||
" Set them in .env (Casdoor app credentials + the owner's "
|
||||
"Casdoor username), or set CASDOOR_ENABLED=false with HOST=127.0.0.1 "
|
||||
"for tokenless local development."
|
||||
)
|
||||
sys.exit(1)
|
||||
elif not loopback:
|
||||
logger.critical(
|
||||
"\n"
|
||||
"❌ CASDOOR_ENABLED=false but HOST binds beyond loopback "
|
||||
f"({settings.host}).\n"
|
||||
" Every surface (REST, WebSocket, MCP make_call) would be open "
|
||||
"to the network.\n"
|
||||
" Set API_TOKEN in .env (e.g. `openssl rand -hex 32`), or set "
|
||||
"HOST=127.0.0.1 for tokenless local development."
|
||||
" Every surface (REST, WebSocket, MCP make_call) would resolve "
|
||||
"to the dev owner — open to the network.\n"
|
||||
" Set CASDOOR_ENABLED=true (with the Casdoor + OWNER_NAME settings), "
|
||||
"or set HOST=127.0.0.1 for tokenless local development."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -124,8 +150,18 @@ def _check_startup_config(settings: Settings) -> None:
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup: Initialize database, SIP engine, and services."""
|
||||
settings = get_settings()
|
||||
|
||||
# Re-apply: under `uvicorn main:app` the server installs its own handlers on
|
||||
# `uvicorn`/`uvicorn.access` after importing this module, which would emit
|
||||
# colourised text alongside our JSON. This takes them back over.
|
||||
configure_logging(settings.log_format, settings.log_level)
|
||||
|
||||
_check_startup_config(settings)
|
||||
|
||||
# Prefetch Casdoor's JWKS so the first authenticated request doesn't pay
|
||||
# the network round-trip (no-op when SSO is disabled).
|
||||
init_jwks_client()
|
||||
|
||||
# The MCP session manager lives in the mounted sub-app's lifespan;
|
||||
# without entering it, every /mcp request 500s.
|
||||
async with mcp_http_app.lifespan(app):
|
||||
@@ -214,7 +250,11 @@ async def lifespan(app: FastAPI):
|
||||
display_port = int(sys.argv[i + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
auth_state = "bearer token required" if settings.api_token.get_secret_value() else "auth disabled (loopback)"
|
||||
auth_state = (
|
||||
f"Casdoor SSO (owner: {settings.owner_name or 'UNSET'})"
|
||||
if settings.casdoor.enabled
|
||||
else "dev-owner (loopback, no auth)"
|
||||
)
|
||||
logger.info(f" API: http://{display_host}:{display_port} [{auth_state}]")
|
||||
logger.info(f" API Docs: http://{display_host}:{display_port}/docs")
|
||||
logger.info(f" WebSocket: ws://{display_host}:{display_port}/ws/events")
|
||||
@@ -236,12 +276,92 @@ def _get_gateway_instance() -> AIPSTNGateway | None:
|
||||
return getattr(app.state, "gateway", None)
|
||||
|
||||
|
||||
mcp = create_mcp_server(
|
||||
_get_gateway_instance,
|
||||
api_token=get_settings().api_token.get_secret_value(),
|
||||
)
|
||||
mcp = create_mcp_server(_get_gateway_instance)
|
||||
mcp_http_app = mcp.http_app(path="/")
|
||||
|
||||
|
||||
def _public_base_url(scope_or_request) -> str:
|
||||
"""Resolve this service's public base URL (scheme + host), no trailing slash.
|
||||
|
||||
Precedence: explicit PUBLIC_BASE_URL override → X-Forwarded-Proto/Host
|
||||
(nginx/HAProxy) → Host header → localhost. Accepts either a FastAPI
|
||||
``Request`` or a raw ASGI ``scope`` so the ASGI MCP guard and the FastAPI
|
||||
discovery endpoints share one implementation.
|
||||
"""
|
||||
settings = get_settings()
|
||||
if settings.public_base_url:
|
||||
return settings.public_base_url.rstrip("/")
|
||||
|
||||
if hasattr(scope_or_request, "headers"):
|
||||
headers = {k.lower(): v for k, v in scope_or_request.headers.items()}
|
||||
default_scheme = getattr(scope_or_request.url, "scheme", None) or "http"
|
||||
else:
|
||||
headers = {
|
||||
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope_or_request.get("headers", [])
|
||||
}
|
||||
default_scheme = scope_or_request.get("scheme", "http")
|
||||
|
||||
proto = (headers.get("x-forwarded-proto") or default_scheme).split(",", 1)[0].strip()
|
||||
host = (headers.get("x-forwarded-host") or headers.get("host") or "localhost")
|
||||
host = host.split(",", 1)[0].strip()
|
||||
return f"{proto}://{host}"
|
||||
|
||||
|
||||
def _owner_only_mcp(inner_app):
|
||||
"""Wrap the mounted MCP ASGI app to require an owner bearer token.
|
||||
|
||||
MCP tools reach state via the FastMCP lifespan context, not FastAPI's
|
||||
dependency system, so ``Depends`` can't gate ``/mcp``. Instead we read the
|
||||
ASGI scope's ``Authorization`` header, resolve it (Casdoor JWT or PAT)
|
||||
against a fresh DB session, and short-circuit non-owner requests with
|
||||
401/403. In dev mode this resolves to the dev owner, so local development
|
||||
keeps working without a token.
|
||||
"""
|
||||
|
||||
async def _send_status(send, scope, status: int, body: bytes) -> None:
|
||||
base = _public_base_url(scope)
|
||||
resource_metadata_url = f"{base}/.well-known/oauth-protected-resource/mcp"
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": status,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(
|
||||
b"www-authenticate",
|
||||
f'Bearer realm="hold-slayer-mcp", '
|
||||
f'resource_metadata="{resource_metadata_url}"'.encode(),
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": body})
|
||||
|
||||
async def app(scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await inner_app(scope, receive, send)
|
||||
return
|
||||
|
||||
authorization = None
|
||||
for name, value in scope.get("headers", []):
|
||||
if name == b"authorization":
|
||||
authorization = value.decode("latin-1")
|
||||
break
|
||||
|
||||
async with session_scope() as session:
|
||||
user = await resolve_from_header_or_query(session, authorization, None)
|
||||
if user is None:
|
||||
await _send_status(send, scope, 401, b'{"detail":"Not authenticated"}')
|
||||
return
|
||||
if not is_owner(user):
|
||||
await _send_status(send, scope, 403, b'{"detail":"Owner access required"}')
|
||||
return
|
||||
|
||||
await inner_app(scope, receive, send)
|
||||
|
||||
return app
|
||||
|
||||
app = FastAPI(
|
||||
title="Hold Slayer Gateway",
|
||||
description=(
|
||||
@@ -258,9 +378,13 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
# === API Routes ===
|
||||
# Every protected surface is gated to the owner (Casdoor JWT or PAT). The
|
||||
# unauthenticated OIDC endpoints live on the /auth router (login/callback/…).
|
||||
# call_history must register before calls: both live under /api/v1/calls and
|
||||
# calls' GET /{call_id} would otherwise capture the literal path "history".
|
||||
_auth = [Depends(require_token)]
|
||||
_auth = [Depends(get_current_owner)]
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(tokens.router, dependencies=_auth)
|
||||
app.include_router(
|
||||
call_history.router, prefix="/api/v1/calls", tags=["Call History"], dependencies=_auth
|
||||
)
|
||||
@@ -270,11 +394,128 @@ app.include_router(
|
||||
)
|
||||
app.include_router(devices.router, prefix="/api/v1/devices", tags=["Devices"], dependencies=_auth)
|
||||
app.include_router(routing.router, prefix="/api/v1/routing", tags=["Routing"], dependencies=_auth)
|
||||
# WebSocket endpoints check the token themselves (query param or header)
|
||||
# WebSocket endpoints check the owner themselves (query param or header)
|
||||
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
||||
|
||||
# === MCP (streamable HTTP; clients connect to /mcp/ with the bearer token) ===
|
||||
app.mount("/mcp", mcp_http_app)
|
||||
# === MCP (streamable HTTP; clients connect to /mcp/ with a PAT or JWT) ===
|
||||
# The ASGI guard resolves the bearer to the owner before the inner app runs.
|
||||
app.mount("/mcp", _owner_only_mcp(mcp_http_app))
|
||||
|
||||
# In-memory store of dynamically registered OAuth clients (RFC 7591). MCP
|
||||
# clients re-register each session; the real gate is the bearer token.
|
||||
_registered_clients: dict[str, dict] = {}
|
||||
|
||||
|
||||
@app.get("/.well-known/oauth-protected-resource", include_in_schema=False)
|
||||
@app.get("/.well-known/oauth-protected-resource/mcp", include_in_schema=False)
|
||||
async def oauth_protected_resource_metadata(request: Request):
|
||||
"""RFC 9728 Protected Resource Metadata — points MCP clients at the AS.
|
||||
|
||||
``resource`` advertises ``{base}/mcp`` (not the bare origin) because recent
|
||||
``mcp-remote`` versions verify it matches the URL they connected to.
|
||||
"""
|
||||
base = _public_base_url(request)
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource": f"{base}/mcp",
|
||||
"authorization_servers": [base],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_documentation": f"{base}/docs",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/.well-known/oauth-authorization-server", include_in_schema=False)
|
||||
async def oauth_authorization_server_metadata(request: Request):
|
||||
"""RFC 8414 Authorization Server Metadata.
|
||||
|
||||
When Casdoor SSO is on, the real authorization server is Casdoor — advertise
|
||||
its endpoints. In dev mode there's no OAuth server; clients supply a PAT
|
||||
directly in their MCP configuration.
|
||||
"""
|
||||
base = _public_base_url(request)
|
||||
settings = get_settings()
|
||||
if settings.casdoor.enabled:
|
||||
casdoor_base = settings.casdoor.endpoint.rstrip("/")
|
||||
return JSONResponse(
|
||||
{
|
||||
"issuer": casdoor_base,
|
||||
"authorization_endpoint": f"{casdoor_base}/login/oauth/authorize",
|
||||
"token_endpoint": f"{casdoor_base}/api/login/oauth/access_token",
|
||||
"jwks_uri": f"{casdoor_base}/.well-known/jwks",
|
||||
"registration_endpoint": f"{base}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code"],
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post"],
|
||||
"scopes_supported": ["openid", "profile", "email"],
|
||||
}
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"issuer": base,
|
||||
"authorization_endpoint": f"{base}/auth/login",
|
||||
"token_endpoint": f"{base}/auth/callback",
|
||||
"registration_endpoint": f"{base}/register",
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/register", include_in_schema=False)
|
||||
async def oauth_dynamic_registration(request: Request):
|
||||
"""RFC 7591 Dynamic Client Registration — accept any well-formed request.
|
||||
|
||||
Registered clients are held in memory (ephemeral); the real security gate
|
||||
is the bearer token (PAT or Casdoor JWT) on every /mcp request.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "invalid_client_metadata",
|
||||
"error_description": "Request body must be valid JSON.",
|
||||
},
|
||||
)
|
||||
|
||||
redirect_uris = body.get("redirect_uris")
|
||||
if not redirect_uris or not isinstance(redirect_uris, list):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "invalid_redirect_uri",
|
||||
"error_description": "redirect_uris is required and must be a non-empty list.",
|
||||
},
|
||||
)
|
||||
|
||||
client_id = secrets.token_hex(16)
|
||||
now = int(time.time())
|
||||
_registered_clients[client_id] = {
|
||||
"client_id": client_id,
|
||||
"client_id_issued_at": now,
|
||||
"redirect_uris": redirect_uris,
|
||||
"grant_types": body.get("grant_types", ["authorization_code"]),
|
||||
"response_types": body.get("response_types", ["code"]),
|
||||
"token_endpoint_auth_method": body.get("token_endpoint_auth_method", "none"),
|
||||
"client_name": body.get("client_name"),
|
||||
"scope": body.get("scope"),
|
||||
}
|
||||
logger.info("Registered OAuth client %s (name=%s)", client_id, body.get("client_name"))
|
||||
return JSONResponse(
|
||||
status_code=201,
|
||||
content={
|
||||
"client_id": client_id,
|
||||
"client_id_issued_at": now,
|
||||
"redirect_uris": redirect_uris,
|
||||
"grant_types": _registered_clients[client_id]["grant_types"],
|
||||
"response_types": _registered_clients[client_id]["response_types"],
|
||||
"token_endpoint_auth_method": _registered_clients[client_id][
|
||||
"token_endpoint_auth_method"
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/status", tags=["System"], dependencies=_auth)
|
||||
@@ -387,4 +628,8 @@ if __name__ == "__main__":
|
||||
port=settings.port,
|
||||
reload=settings.debug,
|
||||
log_level=settings.log_level,
|
||||
# Suppress uvicorn's own dictConfig: ours is installed at import and in
|
||||
# lifespan, and letting uvicorn apply its default would attach a second,
|
||||
# colourised handler — every line twice, one of them not JSON.
|
||||
log_config=None,
|
||||
)
|
||||
|
||||
@@ -27,7 +27,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def create_mcp_server(
|
||||
get_gateway: Callable[[], Optional[AIPSTNGateway]],
|
||||
api_token: str = "",
|
||||
) -> FastMCP:
|
||||
"""
|
||||
Create and configure the MCP server with all tools and resources.
|
||||
@@ -35,14 +34,13 @@ def create_mcp_server(
|
||||
The gateway is resolved lazily per request via `get_gateway` so the
|
||||
server can be mounted at app construction, before the lifespan has
|
||||
started the gateway.
|
||||
|
||||
Auth is **not** configured on the FastMCP instance: the mounted `/mcp`
|
||||
ASGI app is gated by `_owner_only_mcp` in main.py, which resolves a
|
||||
Casdoor JWT or PAT to the owner (one resolver shared with REST/WS) —
|
||||
so PATs and JWTs both work here with a single code path.
|
||||
"""
|
||||
auth = None
|
||||
if api_token:
|
||||
from fastmcp.server.auth.providers.jwt import StaticTokenVerifier
|
||||
|
||||
auth = StaticTokenVerifier(tokens={api_token: {"client_id": "hold-slayer"}})
|
||||
|
||||
mcp = FastMCP("Hold Slayer Gateway", auth=auth)
|
||||
mcp = FastMCP("Hold Slayer Gateway", auth=None)
|
||||
|
||||
def require_gateway() -> AIPSTNGateway:
|
||||
gateway = get_gateway()
|
||||
|
||||
@@ -32,9 +32,13 @@ dependencies = [
|
||||
# HTTP client (for Speaches STT)
|
||||
"httpx>=0.28.0",
|
||||
|
||||
# MCP server (3.x — http_app + StaticTokenVerifier)
|
||||
# MCP server (3.x — http_app + ASGI owner guard)
|
||||
"fastmcp>=3.0.0",
|
||||
|
||||
# Auth: Casdoor SSO (OAuth code exchange) + RS256 JWT validation
|
||||
"casdoor>=1.0",
|
||||
"pyjwt[crypto]>=2.8",
|
||||
|
||||
# Utilities
|
||||
"python-slugify>=8.0.0",
|
||||
]
|
||||
@@ -47,6 +51,8 @@ dev = [
|
||||
"httpx>=0.28.0",
|
||||
"ruff>=0.8.0",
|
||||
"aiosqlite>=0.22.0",
|
||||
# Mint RS256 JWTs in tests without a live Casdoor
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -144,7 +144,11 @@ class AudioClassifier:
|
||||
)
|
||||
|
||||
# 5. If it's speech-like, is it live or automated?
|
||||
if speech_score > music_score:
|
||||
# A confident music score wins outright: the speech bands are wide
|
||||
# enough that sustained hold music satisfies all four and scores 1.0,
|
||||
# which would otherwise mask music as LIVE_HUMAN and ring the owner
|
||||
# for a hold queue.
|
||||
if speech_score > music_score and music_score < self.settings.music_threshold:
|
||||
# Use history to distinguish live human from IVR
|
||||
# IVR: repetitive patterns, synthetic prosody
|
||||
# Human: natural variation, conversational rhythm
|
||||
|
||||
213
tests/lab/README.md
Normal file
213
tests/lab/README.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 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.
|
||||
|
||||
## The softphone (transfer target)
|
||||
|
||||
**Asterisk is the registrar for devices, not Hold Slayer.** The gateway reaches
|
||||
a desk phone by dialling extension `2001`, which Asterisk routes to whatever
|
||||
has registered as `softphone`. This deliberately avoids Hold Slayer's own SIP
|
||||
listener, which answers `200 OK` to any REGISTER with no digest challenge.
|
||||
|
||||
The `pjsua` CLI built alongside the Python bindings is the test device — same
|
||||
library stack as the gateway, no extra dependency. It needs an RPATH patch like
|
||||
the bindings did:
|
||||
|
||||
```bash
|
||||
cp ~/src/pjproject/pjsip-apps/bin/pjsua-x86_64-pc-linux-gnu ~/.local/bin/pjsua
|
||||
patchelf --set-rpath $HOME/.local/lib ~/.local/bin/pjsua
|
||||
```
|
||||
|
||||
Register it (config file avoids shell-quoting pain):
|
||||
|
||||
```bash
|
||||
cat > softphone.cfg <<'EOF'
|
||||
--null-audio
|
||||
--auto-answer=200
|
||||
--max-calls=4
|
||||
--local-port=21070
|
||||
--id=sip:softphone@127.0.0.1
|
||||
--registrar=sip:127.0.0.1:21061
|
||||
--realm=asterisk
|
||||
--username=softphone
|
||||
--password=labphone
|
||||
--log-level=3
|
||||
EOF
|
||||
|
||||
# pjsua is an interactive console app: it exits ~8s after start if stdin is
|
||||
# closed or /dev/null. Hold a fifo open on stdin — `script -qfc` and
|
||||
# `setsid </dev/null` both look like they work (registration succeeds) and
|
||||
# then the process dies, leaving a stale contact in Asterisk that routes
|
||||
# INVITEs to a port nobody is listening on.
|
||||
mkfifo sp.fifo
|
||||
setsid sh -c 'exec 3<>sp.fifo; pjsua --config-file softphone.cfg <&3 >softphone.log 2>&1' &
|
||||
```
|
||||
|
||||
Verify — **check the port is actually bound**, not just that Asterisk holds a
|
||||
contact, since a stale registration outlives the process:
|
||||
|
||||
```bash
|
||||
ss -lnup | grep 21070 # must be listening
|
||||
docker compose -f docker-compose.lab.yml exec asterisk \
|
||||
asterisk -rx "pjsip show contacts" # must show softphone
|
||||
```
|
||||
|
||||
Then place a call to `2001`. Both legs should show `Up` under one bridge id:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.lab.yml exec asterisk \
|
||||
asterisk -rx "core show channels concise"
|
||||
```
|
||||
|
||||
> **`--realm=asterisk`, not `--realm='*'`** — the wildcard fails with
|
||||
> `PJSIP_EFAILEDCREDENTIAL` against Asterisk's digest challenge.
|
||||
|
||||
> **Qualify is off** for this AOR (`qualify_frequency = 0`): the pjsua console
|
||||
> does not answer `OPTIONS`, so polling marks a working softphone `Unavail` and
|
||||
> the dialplan refuses to ring it. The `2001` guard therefore tests
|
||||
> `PJSIP_AOR(softphone,contact)` rather than `DEVICE_STATE`. A real hardphone
|
||||
> answers OPTIONS and can have qualify re-enabled.
|
||||
|
||||
## 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.
|
||||
3
tests/lab/dialplan/.gitignore
vendored
Normal file
3
tests/lab/dialplan/.gitignore
vendored
Normal file
@@ -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
|
||||
17
tests/lab/dialplan/asterisk.conf
Normal file
17
tests/lab/dialplan/asterisk.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
; Minimal Asterisk core config for the lab.
|
||||
;
|
||||
; Deliberately does NOT set [directories] or runuser/rungroup: the image's
|
||||
; compiled-in defaults are correct, and it runs as the `asterisk` user via a
|
||||
; USER directive. Overriding either risks breaking the container for no gain
|
||||
; (an earlier version of this file did both).
|
||||
[options]
|
||||
; Log to stdout so Docker's json-file driver captures it and Alloy ships it
|
||||
; to Loki. A file-based log inside the container would be invisible.
|
||||
verbose = 3
|
||||
debug = 0
|
||||
; No ANSI colour. Asterisk colourises the console by default and the escape
|
||||
; codes travel through Docker into Loki, where every line arrives wrapped in
|
||||
; \x1b[0;30m — unreadable in Grafana and awkward to filter on. This setting
|
||||
; lives here, not in logger.conf, and only takes effect if this file is
|
||||
; actually mounted into the container.
|
||||
nocolor = yes
|
||||
161
tests/lab/dialplan/extensions.conf
Normal file
161
tests/lab/dialplan/extensions.conf
Normal file
@@ -0,0 +1,161 @@
|
||||
; ---------------------------------------------------------------------------
|
||||
; 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()
|
||||
|
||||
; --- 2001: ring the registered softphone ----------------------------------
|
||||
; The transfer target. Asterisk is the registrar for devices, so the gateway
|
||||
; reaches a desk phone by dialling this rather than by registering it itself.
|
||||
; Fails fast when nothing is registered — a silent 30s ring would look like a
|
||||
; gateway bug rather than an absent softphone.
|
||||
exten => 2001,1,NoOp(LAB 2001: ring softphone)
|
||||
; Count registered contacts rather than DEVICE_STATE: device state follows
|
||||
; the OPTIONS qualify, which is off for this AOR (the pjsua CLI does not
|
||||
; answer OPTIONS), so a registered softphone would still read UNAVAILABLE.
|
||||
same => n,GotoIf($[${PJSIP_AOR(softphone,contact)} = ""]?nodevice)
|
||||
same => n,Dial(PJSIP/softphone,30)
|
||||
same => n,Hangup()
|
||||
same => n(nodevice),NoOp(LAB 2001: no softphone registered)
|
||||
same => n,Answer()
|
||||
same => n,Playback(lab-speech)
|
||||
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()
|
||||
24
tests/lab/dialplan/logger.conf
Normal file
24
tests/lab/dialplan/logger.conf
Normal file
@@ -0,0 +1,24 @@
|
||||
; Log to stdout only — Docker's json-file driver captures it and the host
|
||||
; Alloy ships it to Loki as job=<compose project>. Writing to a file inside
|
||||
; the container would put the logs where nothing can see them.
|
||||
[general]
|
||||
dateformat = %F %T
|
||||
; Colour is disabled in asterisk.conf (`nocolor = yes`), not here — Asterisk
|
||||
; colourises the console by default and the escape codes travel through Docker
|
||||
; into Loki, where every line arrives wrapped in \x1b[0;30m. That file must be
|
||||
; mounted for the setting to take effect.
|
||||
|
||||
[logfiles]
|
||||
; Warnings and errors only. That covers what matters when a lab call
|
||||
; misbehaves: failed authentication, no-matching-endpoint, playback failures.
|
||||
;
|
||||
; `notice` and `verbose` are both excluded because the "Remote UNIX
|
||||
; connection" pairs the healthcheck generates arrive on those channels, and
|
||||
; they swamped everything else (the Loki stream measured 100% healthcheck
|
||||
; noise before this). The healthcheck itself is also reduced to one CLI call
|
||||
; on a 60s interval in docker-compose — the two changes work together.
|
||||
;
|
||||
; To trace a call's dialplan execution, raise verbosity at runtime rather than
|
||||
; leaving it on:
|
||||
; asterisk -rx "core set verbose 3"
|
||||
console => warning,error
|
||||
132
tests/lab/dialplan/pjsip.conf
Normal file
132
tests/lab/dialplan/pjsip.conf
Normal file
@@ -0,0 +1,132 @@
|
||||
; ---------------------------------------------------------------------------
|
||||
; 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 *and port*. 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 came from sidesteps that.
|
||||
;
|
||||
; The port is essential when the softphone runs on the same host: a
|
||||
; host-only match claims *every* packet from that address, so the
|
||||
; softphone's REGISTER would be attributed to this endpoint and checked
|
||||
; against the gateway's password ("Failed to authenticate", confusingly).
|
||||
; Endpoints that authenticate by username (the softphone) must not be
|
||||
; covered by an identify block.
|
||||
[hold-slayer]
|
||||
type = identify
|
||||
endpoint = hold-slayer
|
||||
match = {{ asterisk_match_host }}:{{ asterisk_gateway_port }}
|
||||
|
||||
[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 }}
|
||||
|
||||
; ---------------------------------------------------------------------------
|
||||
; Softphone endpoint — the transfer target
|
||||
; ---------------------------------------------------------------------------
|
||||
; Asterisk is the registrar for devices, not Hold Slayer. A softphone REGISTERs
|
||||
; here and the gateway transfers a live call to it by dialling extension 2001.
|
||||
;
|
||||
; Deliberate: Hold Slayer's own SIP listener answers 200 OK to any REGISTER
|
||||
; with no digest challenge, so anything on the network could register as a
|
||||
; device and receive transferred calls. Keeping registration in Asterisk means
|
||||
; the lab does not depend on that path, and the softphone is authenticated.
|
||||
;
|
||||
; Test with the pjsua CLI built alongside the Python bindings:
|
||||
; pjsua --null-audio --auto-answer=200 \
|
||||
; --id=sip:softphone@<asterisk-host> \
|
||||
; --registrar=sip:<asterisk-host>:21061 \
|
||||
; --realm='*' --username=softphone --password=<pw> \
|
||||
; --local-port=<free port>
|
||||
|
||||
[softphone]
|
||||
type = endpoint
|
||||
context = hold-slayer-lab
|
||||
disallow = all
|
||||
allow = ulaw
|
||||
allow = alaw
|
||||
auth = softphone-auth
|
||||
aors = softphone
|
||||
dtmf_mode = rfc4733
|
||||
direct_media = no
|
||||
force_rport = yes
|
||||
rewrite_contact = yes
|
||||
rtp_symmetric = yes
|
||||
|
||||
[softphone-auth]
|
||||
type = auth
|
||||
auth_type = userpass
|
||||
username = {{ asterisk_softphone_username }}
|
||||
password = {{ asterisk_softphone_password }}
|
||||
|
||||
[softphone]
|
||||
type = aor
|
||||
; The device's contact is learned from its REGISTER rather than configured —
|
||||
; a softphone's port is not known in advance.
|
||||
max_contacts = 1
|
||||
remove_existing = yes
|
||||
; No qualify: the pjsua CLI does not answer OPTIONS while sitting at its
|
||||
; console prompt, so polling marks a perfectly working softphone Unavail and
|
||||
; the dialplan refuses to ring it. Registration itself is the liveness signal
|
||||
; here. A real hardphone answers OPTIONS and can have qualify re-enabled.
|
||||
qualify_frequency = 0
|
||||
|
||||
[hold-slayer]
|
||||
type = aor
|
||||
max_contacts = 2
|
||||
remove_existing = yes
|
||||
qualify_frequency = 60
|
||||
9
tests/lab/dialplan/rtp.conf
Normal file
9
tests/lab/dialplan/rtp.conf
Normal file
@@ -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 }}
|
||||
42
tests/lab/docker-compose.lab.yml
Normal file
42
tests/lab/docker-compose.lab.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
# 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
|
||||
# Carries `nocolor = yes`: without it every log line reaches Loki
|
||||
# wrapped in ANSI escape codes.
|
||||
- ./dialplan/asterisk.conf:/etc/asterisk/asterisk.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
|
||||
# The image's default command is `-vvvdddf` — verbosity 3 and debug 3
|
||||
# forced on the command line, which overrides both asterisk.conf and
|
||||
# logger.conf. That makes every healthcheck CLI connection log a
|
||||
# "Remote UNIX connection" pair: ~2900 lines/day of pure noise that
|
||||
# completely buried the real SIP events in Loki.
|
||||
#
|
||||
# -f foreground (required: Docker needs PID 1 to stay), -T timestamps,
|
||||
# -W colour off, -U run as asterisk, -p realtime priority. No -v, no -d:
|
||||
# warnings and errors still log, and verbosity can be raised at runtime
|
||||
# with `asterisk -rx "core set verbose 3"` when tracing a call.
|
||||
command: ["/usr/sbin/asterisk", "-f", "-T", "-W", "-U", "asterisk", "-p"]
|
||||
restart: unless-stopped
|
||||
4
tests/lab/sounds/.gitignore
vendored
Normal file
4
tests/lab/sounds/.gitignore
vendored
Normal file
@@ -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
|
||||
150
tests/lab/sounds/generate.py
Normal file
150
tests/lab/sounds/generate.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/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 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("<i2")
|
||||
path.write_bytes(pcm.tobytes())
|
||||
print(f" {path.name}: {len(pcm) / RATE:.1f}s ({path.stat().st_size} bytes)")
|
||||
|
||||
|
||||
def make_music(seconds: float = 30.0, seed: int = 7) -> 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.
|
||||
"""
|
||||
rng = np.random.default_rng(seed)
|
||||
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 four harmonics, decaying — a plucked-string
|
||||
# feel. Enough harmonics to keep spectral flatness inside the
|
||||
# music score's 0.05-0.4 band: with only three, some windows fall
|
||||
# *below* 0.05 (too pure to read as music) and score as speech.
|
||||
for h, amp in ((1, 0.30), (2, 0.12), (3, 0.05), (4, 0.03), (5, 0.02)):
|
||||
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
|
||||
|
||||
# Recording-style noise floor. Windows straddling a chord change have a
|
||||
# momentarily sparse spectrum and land just *under* the music score's
|
||||
# 0.05 flatness floor, scoring as speech. This is well below the level
|
||||
# that would disturb tonality — every real recording has one.
|
||||
out += rng.normal(0, 0.004, len(out))
|
||||
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
|
||||
frac = local / syl
|
||||
|
||||
# Pitch CONTOUR, not a constant. This is the single feature that
|
||||
# separates this fixture from music. `_detect_tonality` looks for
|
||||
# an autocorrelation peak > 0.5 in the 50-1000 Hz lag range; a
|
||||
# fixed f0 is perfectly periodic there, scores is_tonal=True, and
|
||||
# hands the music score a free 0.3 that speech cannot outrun.
|
||||
# Real voices glide and jitter, so the periodicity never locks.
|
||||
f0_start = rng.uniform(95, 165)
|
||||
f0_end = f0_start * rng.uniform(0.72, 1.38) # rise or fall
|
||||
f0 = f0_start + (f0_end - f0_start) * frac
|
||||
# Cycle-to-cycle jitter on top of the glide (~2% is human).
|
||||
f0 *= 1.0 + 0.02 * rng.standard_normal(len(local))
|
||||
# Integrate frequency to phase — with a varying f0, `2*pi*f*t`
|
||||
# would be wrong (that is a chirp only if f is the *instantaneous*
|
||||
# rate, which it is not once f0 itself moves).
|
||||
ph0 = 2 * np.pi * np.cumsum(f0) / RATE
|
||||
|
||||
# Two formants, swept 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) * frac
|
||||
f2 = rng.uniform(1750, 2600) + rng.uniform(-120, 120) * frac
|
||||
sig = (0.50 * np.sin(ph0)
|
||||
+ 0.30 * np.sin(2 * np.pi * f1 * local)
|
||||
+ 0.18 * np.sin(2 * np.pi * f2 * local))
|
||||
# Aspiration noise — HIGH-PASSED, not broadband. Real speech noise
|
||||
# sits above the formants; flat noise puts energy in every
|
||||
# Goertzel bin, so the strongest DTMF row and column both clear
|
||||
# the detector's `total_power * 0.1` threshold and every syllable
|
||||
# reads as a keypress. A first-difference filter (y[n]-y[n-1]) is
|
||||
# a cheap +6dB/octave tilt that leaves the 697-1633 Hz DTMF bands
|
||||
# comparatively empty. The 0.09 level is chosen for margin: it puts
|
||||
# spectral flatness at ~0.46, mid-way through the 0.1-0.5 band the
|
||||
# speech score rewards, rather than on either edge.
|
||||
noise = rng.standard_normal(len(local) + 1)
|
||||
sig += 0.09 * np.diff(noise)
|
||||
# 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()
|
||||
@@ -1,24 +1,53 @@
|
||||
"""
|
||||
API surface tests — bearer-token enforcement and route registration order.
|
||||
API surface tests — owner enforcement and route registration order.
|
||||
|
||||
The app is exercised without its lifespan: auth runs before any handler,
|
||||
so a 503 ("Gateway not initialized") proves the token was accepted.
|
||||
so a 503 ("Gateway not initialized") proves the caller was accepted as
|
||||
owner. Auth internals (JWT/PAT resolution) are covered in test_auth.py;
|
||||
here we assert the routers are gated and the routes register in the right
|
||||
order. In dev-owner mode (SSO disabled) a tokenless request is the owner.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from starlette.routing import Match
|
||||
|
||||
import db.database as dbmod
|
||||
import main
|
||||
from config import get_settings
|
||||
|
||||
TOKEN = "test-token-for-suite"
|
||||
from db.database import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token_enabled(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(TOKEN))
|
||||
async def mem_db(monkeypatch):
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dev_owner(monkeypatch):
|
||||
"""SSO disabled — every request resolves to the dev owner."""
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sso_enabled(monkeypatch):
|
||||
"""SSO enabled with no credentials supplied → 401 on protected routes."""
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -28,45 +57,34 @@ async def client():
|
||||
yield c
|
||||
|
||||
|
||||
class TestBearerToken:
|
||||
async def test_missing_token_rejected(self, token_enabled, client):
|
||||
class TestOwnerGate:
|
||||
async def test_dev_owner_reaches_handler(self, dev_owner, mem_db, client):
|
||||
resp = await client.get("/api/v1/calls/active")
|
||||
assert resp.status_code == 503 # dev-owner accepted; handler 503s (no lifespan)
|
||||
|
||||
async def test_sso_missing_credentials_rejected(self, sso_enabled, mem_db, client):
|
||||
resp = await client.get("/api/v1/calls/active")
|
||||
assert resp.status_code == 401
|
||||
assert resp.headers["www-authenticate"] == "Bearer"
|
||||
|
||||
async def test_wrong_token_rejected(self, token_enabled, client):
|
||||
resp = await client.get(
|
||||
"/api/v1/calls/active", headers={"Authorization": "Bearer wrong"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_valid_token_reaches_handler(self, token_enabled, client):
|
||||
resp = await client.get(
|
||||
"/api/v1/calls/active", headers={"Authorization": f"Bearer {TOKEN}"}
|
||||
)
|
||||
# No lifespan ran, so the handler itself 503s — auth was accepted
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_empty_token_disables_auth(self, monkeypatch, client):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
||||
resp = await client.get("/api/v1/calls/active")
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_query_param_token_accepted(self, token_enabled, client):
|
||||
"""<audio>/<a> elements can't set headers — ?token= must work."""
|
||||
resp = await client.get(f"/api/v1/calls/active?token={TOKEN}")
|
||||
assert resp.status_code == 503 # auth accepted, handler 503s (no lifespan)
|
||||
|
||||
async def test_wrong_query_param_token_rejected(self, token_enabled, client):
|
||||
resp = await client.get("/api/v1/calls/active?token=wrong")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_all_api_routers_protected(self, token_enabled, client):
|
||||
for path in ("/api/v1/calls/active", "/api/v1/call-flows/", "/api/v1/devices/",
|
||||
"/api/v1/routing/rules", "/api/v1/calls/history"):
|
||||
async def test_all_api_routers_protected(self, sso_enabled, mem_db, client):
|
||||
for path in (
|
||||
"/api/v1/calls/active",
|
||||
"/api/v1/call-flows/",
|
||||
"/api/v1/devices/",
|
||||
"/api/v1/routing/rules",
|
||||
"/api/v1/calls/history",
|
||||
"/api/v1/tokens",
|
||||
):
|
||||
resp = await client.get(path)
|
||||
assert resp.status_code == 401, path
|
||||
|
||||
async def test_auth_routes_are_public(self, sso_enabled, mem_db, client):
|
||||
"""The OIDC endpoints must be reachable without a token."""
|
||||
# /auth/me with no token → 401 (not 403); /auth/login → redirect to Casdoor
|
||||
resp = await client.get("/auth/login", follow_redirects=False)
|
||||
assert resp.status_code in (302, 307)
|
||||
|
||||
|
||||
class TestRouteOrder:
|
||||
def _resolve(self, path: str):
|
||||
|
||||
239
tests/test_auth.py
Normal file
239
tests/test_auth.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Auth tests — Casdoor JWT + PAT resolution, owner gating, dev-owner mode.
|
||||
|
||||
No live Casdoor: we generate an RSA keypair, stub the JWKS client so
|
||||
`_decode_casdoor_jwt` trusts our public key, and mint RS256 JWTs locally.
|
||||
The app is exercised without its lifespan, so a 503 ("Gateway not
|
||||
initialized") proves auth was accepted and the request reached a handler.
|
||||
|
||||
DB access (resolve_bearer → users/PATs) hits an in-memory SQLite database
|
||||
wired in via the `mem_db` fixture, mirroring tests/test_data_layer.py.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
import auth as authmod
|
||||
import db.database as dbmod
|
||||
import main
|
||||
from config import get_settings
|
||||
from db.database import Base, PersonalAccessToken, User
|
||||
|
||||
ENDPOINT = "https://id.example.test"
|
||||
OWNER = "owner@example.test"
|
||||
|
||||
|
||||
# ── RSA keypair + JWKS stub ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def keypair():
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
private_pem = key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
return private_pem, key.public_key()
|
||||
|
||||
|
||||
def _mint(private_pem, *, sub, name, email=None, exp_delta=3600):
|
||||
claims = {
|
||||
"iss": ENDPOINT,
|
||||
"sub": sub,
|
||||
"name": name,
|
||||
"displayName": name,
|
||||
"exp": int(time.time()) + exp_delta,
|
||||
"iat": int(time.time()),
|
||||
}
|
||||
if email:
|
||||
claims["email"] = email
|
||||
return jwt.encode(claims, private_pem, algorithm="RS256")
|
||||
|
||||
|
||||
class _StubJWKS:
|
||||
"""Stands in for jwt.PyJWKClient — returns our fixed public key."""
|
||||
|
||||
def __init__(self, public_key):
|
||||
self._key = public_key
|
||||
|
||||
def get_signing_key_from_jwt(self, token):
|
||||
class _K:
|
||||
key = self._key
|
||||
|
||||
return _K()
|
||||
|
||||
def fetch_data(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sso_enabled(monkeypatch, keypair):
|
||||
"""Enable Casdoor SSO with a known owner and a stubbed JWKS client."""
|
||||
_, public_key = keypair
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings.casdoor, "enabled", True)
|
||||
monkeypatch.setattr(settings.casdoor, "endpoint", ENDPOINT)
|
||||
monkeypatch.setattr(settings, "owner_name", OWNER)
|
||||
monkeypatch.setattr(authmod, "_jwks_client", _StubJWKS(public_key))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db(monkeypatch):
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _seed_user(factory, *, name, casdoor_sub=None, email=None) -> str:
|
||||
uid = uuid.uuid4().hex
|
||||
async with factory() as session:
|
||||
session.add(
|
||||
User(id=uid, name=name, display_name=name, email=email, casdoor_sub=casdoor_sub)
|
||||
)
|
||||
await session.commit()
|
||||
return uid
|
||||
|
||||
|
||||
async def _seed_pat(factory, user_id, *, revoked=False, expires_at=None) -> str:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
plaintext = authmod.PAT_PREFIX + uuid.uuid4().hex
|
||||
async with factory() as session:
|
||||
pat = PersonalAccessToken(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id=user_id,
|
||||
name="test",
|
||||
token_hash=authmod.hash_token(plaintext),
|
||||
token_prefix=plaintext[: len(authmod.PAT_PREFIX) + 4],
|
||||
revoked_at=(datetime.now(UTC) if revoked else None),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(pat)
|
||||
await session.commit()
|
||||
return plaintext
|
||||
|
||||
|
||||
PROTECTED = "/api/v1/calls/active"
|
||||
|
||||
|
||||
# ── JWT paths ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCasdoorJWT:
|
||||
async def test_owner_jwt_reaches_handler(self, sso_enabled, mem_db, client, keypair):
|
||||
private_pem, _ = keypair
|
||||
token = _mint(private_pem, sub="s-owner", name=OWNER, email=OWNER)
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 503 # auth accepted; no lifespan → handler 503s
|
||||
|
||||
async def test_non_owner_jwt_forbidden(self, sso_enabled, mem_db, client, keypair):
|
||||
private_pem, _ = keypair
|
||||
token = _mint(private_pem, sub="s-guest", name="guest@example.test")
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_expired_jwt_unauthenticated(self, sso_enabled, mem_db, client, keypair):
|
||||
private_pem, _ = keypair
|
||||
token = _mint(private_pem, sub="s-owner", name=OWNER, exp_delta=-10)
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_garbage_token_unauthenticated(self, sso_enabled, mem_db, client):
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": "Bearer not.a.jwt"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_no_credentials_unauthenticated(self, sso_enabled, mem_db, client):
|
||||
resp = await client.get(PROTECTED)
|
||||
assert resp.status_code == 401
|
||||
assert resp.headers["www-authenticate"] == "Bearer"
|
||||
|
||||
|
||||
# ── PAT paths ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPAT:
|
||||
async def test_owner_pat_reaches_handler(self, sso_enabled, mem_db, client):
|
||||
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
|
||||
pat = await _seed_pat(mem_db, uid)
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||
assert resp.status_code == 503
|
||||
|
||||
async def test_non_owner_pat_forbidden(self, sso_enabled, mem_db, client):
|
||||
uid = await _seed_user(mem_db, name="guest@example.test", casdoor_sub="s-guest")
|
||||
pat = await _seed_pat(mem_db, uid)
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_revoked_pat_unauthenticated(self, sso_enabled, mem_db, client):
|
||||
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
|
||||
pat = await _seed_pat(mem_db, uid, revoked=True)
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_expired_pat_unauthenticated(self, sso_enabled, mem_db, client):
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
uid = await _seed_user(mem_db, name=OWNER, casdoor_sub="s-owner", email=OWNER)
|
||||
pat = await _seed_pat(mem_db, uid, expires_at=datetime.now(UTC) - timedelta(minutes=1))
|
||||
resp = await client.get(PROTECTED, headers={"Authorization": f"Bearer {pat}"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_unknown_pat_unauthenticated(self, sso_enabled, mem_db, client):
|
||||
resp = await client.get(
|
||||
PROTECTED, headers={"Authorization": f"Bearer {authmod.PAT_PREFIX}nope"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ── Dev-owner mode (SSO disabled) ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDevOwnerMode:
|
||||
async def test_tokenless_request_is_owner(self, monkeypatch, mem_db, client):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
resp = await client.get(PROTECTED)
|
||||
assert resp.status_code == 503 # dev-owner resolved; handler 503s (no lifespan)
|
||||
|
||||
async def test_auth_me_reports_owner(self, monkeypatch, mem_db, client):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
resp = await client.get("/auth/me")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["is_owner"] is True
|
||||
|
||||
|
||||
# ── /auth/me for a non-owner (200 + is_owner:false, not a hard 401) ──────────
|
||||
|
||||
|
||||
class TestAuthMe:
|
||||
async def test_non_owner_gets_200_not_owner(self, sso_enabled, mem_db, client, keypair):
|
||||
private_pem, _ = keypair
|
||||
token = _mint(private_pem, sub="s-guest", name="guest@example.test")
|
||||
resp = await client.get("/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_owner"] is False
|
||||
187
tests/test_graceful_degradation.py
Normal file
187
tests/test_graceful_degradation.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Graceful-degradation tests.
|
||||
|
||||
Every external dependency — STT, LLM, TTS — is reachable over the network and
|
||||
can be down. The gateway's rule is that a dead dependency degrades the call
|
||||
rather than killing it, and says so: each failure publishes an `ERROR` event
|
||||
naming the service, so a down Speaches reads as "transcription failed" rather
|
||||
than "the AI is making bad decisions".
|
||||
|
||||
The behaviour is already implemented across the services; these tests exist so a
|
||||
later refactor can't quietly remove it. The failure mode being guarded against is
|
||||
silent: an un-caught exception in one of these paths aborts a live phone call.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from config import Settings
|
||||
from core.event_bus import EventBus
|
||||
from models.events import EventType
|
||||
|
||||
|
||||
def _gateway():
|
||||
"""A gateway stand-in with a real event bus, so events can be asserted."""
|
||||
gw = MagicMock()
|
||||
gw.settings = Settings(database_url="sqlite+aiosqlite:///:memory:")
|
||||
gw.event_bus = EventBus()
|
||||
gw.call_manager = MagicMock()
|
||||
gw.call_manager.add_transcript = AsyncMock()
|
||||
return gw
|
||||
|
||||
|
||||
def _hold_slayer(gateway, transcription):
|
||||
from services.audio_classifier import AudioClassifier
|
||||
from services.hold_slayer import HoldSlayerService
|
||||
|
||||
return HoldSlayerService(
|
||||
gateway=gateway,
|
||||
call_manager=gateway.call_manager,
|
||||
sip_engine=MagicMock(),
|
||||
classifier=AudioClassifier(gateway.settings.classifier),
|
||||
transcription=transcription,
|
||||
settings=gateway.settings,
|
||||
)
|
||||
|
||||
|
||||
async def _errors_for(bus: EventBus, coro):
|
||||
"""Run `coro` while subscribed, returning the ERROR events it published."""
|
||||
import asyncio
|
||||
|
||||
sub = bus.subscribe(event_types={EventType.ERROR})
|
||||
try:
|
||||
result = await coro
|
||||
seen = []
|
||||
while True:
|
||||
try:
|
||||
seen.append(sub._queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
return result, seen
|
||||
finally:
|
||||
bus.unsubscribe(sub)
|
||||
|
||||
|
||||
class TestClassifierWithoutSTT:
|
||||
"""The classifier is spectral: it must not depend on STT at all."""
|
||||
|
||||
def _classifier(self):
|
||||
from services.audio_classifier import AudioClassifier
|
||||
|
||||
return AudioClassifier(Settings(database_url="sqlite+aiosqlite:///:memory:").classifier)
|
||||
|
||||
def test_classify_takes_only_audio(self):
|
||||
# A transcript parameter would make STT a hard dependency of hold
|
||||
# detection — the thing this checkbox is about.
|
||||
import inspect
|
||||
|
||||
params = set(inspect.signature(self._classifier().classify_chunk).parameters)
|
||||
assert params == {"audio_data"}
|
||||
|
||||
async def test_classifies_with_no_stt_service_anywhere(self):
|
||||
# Silence is the cheapest deterministic input; the point is that a
|
||||
# classification is produced at all with no STT in the picture.
|
||||
result = await self._classifier().classify(b"\x00\x00" * 16000)
|
||||
assert result.audio_type is not None
|
||||
|
||||
|
||||
class TestTranscriptionDegradation:
|
||||
async def test_hold_slayer_transcribe_returns_empty_on_failure(self):
|
||||
gw = _gateway()
|
||||
stt = MagicMock()
|
||||
stt.transcribe = AsyncMock(side_effect=RuntimeError("Connection refused"))
|
||||
svc = _hold_slayer(gw, stt)
|
||||
|
||||
text, errors = await _errors_for(gw.event_bus, svc._transcribe("call-1", b"\x00" * 320))
|
||||
|
||||
assert text == "" # empty transcript, not an exception
|
||||
assert len(errors) == 1
|
||||
assert errors[0].data["service"] == "transcription"
|
||||
|
||||
async def test_error_event_names_the_service(self):
|
||||
# "transcription failed" vs "the AI decided badly" — the whole reason
|
||||
# transcribe() raises instead of swallowing.
|
||||
gw = _gateway()
|
||||
stt = MagicMock()
|
||||
stt.transcribe = AsyncMock(side_effect=RuntimeError("Connection refused"))
|
||||
svc = _hold_slayer(gw, stt)
|
||||
|
||||
_, errors = await _errors_for(gw.event_bus, svc._transcribe("call-1", b"\x00" * 320))
|
||||
assert "Connection refused" in errors[0].data["error"]
|
||||
|
||||
async def test_service_error_survives_a_dead_event_bus(self):
|
||||
# Degradation reporting must not itself become a failure path.
|
||||
gw = _gateway()
|
||||
gw.event_bus.publish = AsyncMock(side_effect=RuntimeError("bus down"))
|
||||
stt = MagicMock()
|
||||
stt.transcribe = AsyncMock(side_effect=RuntimeError("stt down"))
|
||||
svc = _hold_slayer(gw, stt)
|
||||
|
||||
assert await svc._transcribe("call-1", b"\x00" * 320) == ""
|
||||
|
||||
async def test_transcription_marks_itself_unavailable(self):
|
||||
# /health reads this flag; a failure that doesn't record itself makes
|
||||
# the probe lie.
|
||||
import httpx
|
||||
|
||||
from services.transcription import TranscriptionService
|
||||
|
||||
svc = TranscriptionService(Settings(database_url="sqlite+aiosqlite:///:memory:").speaches)
|
||||
client = MagicMock()
|
||||
client.post = AsyncMock(side_effect=httpx.ConnectError("refused"))
|
||||
svc._client = client
|
||||
svc._client.is_closed = False
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await svc.transcribe(b"\x00" * 320)
|
||||
assert svc.available is False
|
||||
|
||||
|
||||
class TestReceptionistDegradation:
|
||||
def _receptionist(self, gateway, **kw):
|
||||
from services.receptionist import ReceptionistService
|
||||
|
||||
return ReceptionistService(gateway=gateway, **kw)
|
||||
|
||||
async def test_llm_failure_falls_back_to_a_usable_decision(self):
|
||||
gw = _gateway()
|
||||
svc = self._receptionist(gw)
|
||||
call = MagicMock(id="call-1", remote_number="+15551234567")
|
||||
|
||||
llm = MagicMock()
|
||||
llm.chat_json = AsyncMock(side_effect=RuntimeError("LLM down"))
|
||||
import services.llm_client as llm_mod
|
||||
|
||||
original = llm_mod.get_llm
|
||||
llm_mod.get_llm = lambda: llm
|
||||
try:
|
||||
result, errors = await _errors_for(
|
||||
gw.event_bus, svc._classify(call, "I need to speak to someone", None)
|
||||
)
|
||||
finally:
|
||||
llm_mod.get_llm = original
|
||||
|
||||
# A decision still comes back, so the call can proceed.
|
||||
assert result["recommended_action"] in {"ring", "message", "reject"}
|
||||
assert errors[0].data["service"] == "llm"
|
||||
|
||||
async def test_no_transcription_service_yields_empty_not_crash(self):
|
||||
# transcription=None is a valid wiring (STT not configured).
|
||||
gw = _gateway()
|
||||
svc = self._receptionist(gw, transcription=None)
|
||||
assert svc.transcription is None
|
||||
|
||||
|
||||
class TestHealthReportsDegradation:
|
||||
"""A degraded gateway must read as degraded — /health may not lie."""
|
||||
|
||||
def test_availability_helper_distinguishes_unknown_from_down(self):
|
||||
# Four distinct states, because "not wired up" and "wired up but the
|
||||
# remote is refusing connections" are different operator problems.
|
||||
import main
|
||||
|
||||
assert main._availability(None) == "not attached"
|
||||
assert main._availability(MagicMock(available=None)) == "unknown (no requests yet)"
|
||||
assert main._availability(MagicMock(available=True)) == "ok"
|
||||
assert main._availability(MagicMock(available=False)) == "unreachable"
|
||||
@@ -260,6 +260,11 @@ class TestMockSIPEngine:
|
||||
status = await engine.get_trunk_status()
|
||||
assert status["registered"] is False
|
||||
|
||||
# Starting the mock engine must NOT report a registered trunk: /health
|
||||
# requires a registered trunk to be "healthy", so a mock that claimed
|
||||
# registration would let a gateway that cannot place calls go green.
|
||||
await engine.start()
|
||||
status = await engine.get_trunk_status()
|
||||
assert status["registered"] is True
|
||||
assert status["registered"] is False
|
||||
assert status["mock"] is True
|
||||
assert status["reason"] == "No SIP trunk configured (mock mode)"
|
||||
|
||||
109
tests/test_lab_fixtures.py
Normal file
109
tests/test_lab_fixtures.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Lab audio fixtures — the classifier must agree with what each one claims to be.
|
||||
|
||||
These guard the *fixtures*, not the classifier. `tests/lab/sounds/generate.py`
|
||||
synthesises music/speech/silence that the Asterisk lab plays down a real call;
|
||||
if a fixture drifts into the wrong class, every lab result built on it is
|
||||
quietly meaningless — a hold-music scenario that never classifies as music
|
||||
proves nothing about the hold slayer.
|
||||
|
||||
The first version of these fixtures passed on the opening 3s window and drifted
|
||||
to MUSIC after, which a single-window check would not have caught. Hence the
|
||||
sweep across every window.
|
||||
|
||||
Skipped when the fixtures have not been generated: they are gitignored (~680K,
|
||||
reproducible from a fixed seed), so a fresh checkout has none until
|
||||
`python tests/lab/sounds/generate.py` runs.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from config import Settings
|
||||
from models.call import AudioClassification
|
||||
from services.audio_classifier import SAMPLE_RATE, AudioClassifier
|
||||
|
||||
SOUNDS_DIR = Path(__file__).parent / "lab" / "sounds"
|
||||
GENERATOR = SOUNDS_DIR / "generate.py"
|
||||
|
||||
# The lab writes 8 kHz .sln; the classifier works at 16 kHz.
|
||||
LAB_RATE = 8000
|
||||
WINDOW_SAMPLES = SAMPLE_RATE * 3 # classifier's 3s analysis window
|
||||
|
||||
FIXTURES = [
|
||||
("lab-music.sln", AudioClassification.MUSIC),
|
||||
("lab-speech.sln", AudioClassification.LIVE_HUMAN),
|
||||
("lab-silence.sln", AudioClassification.SILENCE),
|
||||
]
|
||||
|
||||
|
||||
def _load_16k(path: Path) -> np.ndarray:
|
||||
"""Load an 8 kHz .sln and upsample to the classifier's 16 kHz."""
|
||||
return np.repeat(np.fromfile(path, dtype="<i2"), SAMPLE_RATE // LAB_RATE)
|
||||
|
||||
|
||||
def _windows(samples: np.ndarray, step: int):
|
||||
"""Yield successive analysis windows; at least one, even for short files."""
|
||||
end = max(1, len(samples) - WINDOW_SAMPLES)
|
||||
for offset in range(0, end, step):
|
||||
yield samples[offset : offset + WINDOW_SAMPLES].astype("<i2").tobytes()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def classifier():
|
||||
return AudioClassifier(settings=Settings().classifier)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename,expected", FIXTURES)
|
||||
def test_fixture_classifies_correctly_in_every_window(filename, expected, classifier):
|
||||
"""Every window must classify correctly — not just the first.
|
||||
|
||||
Stepped at half the window length so windows overlap: a fixture that only
|
||||
works on aligned boundaries would still be a trap in a live call, where
|
||||
the window has no relationship to where the audio started.
|
||||
"""
|
||||
path = SOUNDS_DIR / filename
|
||||
if not path.exists():
|
||||
pytest.skip(f"{filename} not generated — run {GENERATOR}")
|
||||
|
||||
samples = _load_16k(path)
|
||||
results = [
|
||||
classifier.classify_chunk(w).audio_type
|
||||
for w in _windows(samples, step=WINDOW_SAMPLES // 2)
|
||||
]
|
||||
|
||||
wrong = [(i, r.value) for i, r in enumerate(results) if r is not expected]
|
||||
assert not wrong, (
|
||||
f"{filename} must classify as {expected.value} in all "
|
||||
f"{len(results)} windows; wrong: {wrong}"
|
||||
)
|
||||
|
||||
|
||||
def test_generator_is_deterministic(tmp_path):
|
||||
"""Same bytes on every run — the whole point of synthesising them.
|
||||
|
||||
Real hold music varies per call, so a classifier regression on the PSTN is
|
||||
indistinguishable from noise. Fixed-seed audio makes the answer binary.
|
||||
"""
|
||||
if not GENERATOR.exists():
|
||||
pytest.skip("generator not present")
|
||||
|
||||
def run(target: Path) -> dict[str, bytes]:
|
||||
subprocess.run(
|
||||
[sys.executable, str(GENERATOR), str(target)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return {p.name: p.read_bytes() for p in sorted(target.glob("*.sln"))}
|
||||
|
||||
first = run(tmp_path / "a")
|
||||
second = run(tmp_path / "b")
|
||||
|
||||
assert first, "generator produced no .sln files"
|
||||
assert first.keys() == second.keys()
|
||||
for name in first:
|
||||
assert first[name] == second[name], f"{name} differs between runs"
|
||||
183
tests/test_logging_config.py
Normal file
183
tests/test_logging_config.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Structured-logging tests.
|
||||
|
||||
The two things worth guarding are the ones that are easy to break silently:
|
||||
uvicorn's access logger must actually route through our formatter (it sets
|
||||
`propagate = False` and brings its own handler), and its arg tuple must land as
|
||||
real JSON fields rather than a pre-formatted string.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import logging.config
|
||||
|
||||
import pytest
|
||||
|
||||
from config import Settings
|
||||
from core.logging_config import JSONFormatter, configure_logging
|
||||
|
||||
|
||||
def _record(name="test.logger", level=logging.INFO, msg="hello", args=None, **extra):
|
||||
record = logging.LogRecord(
|
||||
name=name, level=level, pathname=__file__, lineno=1, msg=msg, args=args, exc_info=None
|
||||
)
|
||||
for key, value in extra.items():
|
||||
setattr(record, key, value)
|
||||
return record
|
||||
|
||||
|
||||
def _emit(record):
|
||||
return json.loads(JSONFormatter().format(record))
|
||||
|
||||
|
||||
class TestJSONFormatter:
|
||||
def test_emits_single_line_json_with_core_fields(self):
|
||||
out = JSONFormatter().format(_record())
|
||||
assert "\n" not in out
|
||||
payload = json.loads(out)
|
||||
assert payload["msg"] == "hello"
|
||||
assert payload["level"] == "INFO"
|
||||
assert payload["logger"] == "test.logger"
|
||||
|
||||
def test_timestamp_is_utc_rfc3339_with_date(self):
|
||||
# logging's default asctime is local-time and date-less, which is
|
||||
# exactly what makes text logs hard to correlate in Loki.
|
||||
ts = _emit(_record())["ts"]
|
||||
assert ts.endswith("+00:00")
|
||||
assert "T" in ts
|
||||
|
||||
def test_interpolates_message_args(self):
|
||||
assert _emit(_record(msg="call %s ended", args=("abc123",)))["msg"] == "call abc123 ended"
|
||||
|
||||
def test_extra_fields_are_promoted(self):
|
||||
payload = _emit(_record(call_id="c-1", duration=12.5))
|
||||
assert payload["call_id"] == "c-1"
|
||||
assert payload["duration"] == 12.5
|
||||
|
||||
def test_uvicorn_color_message_is_dropped(self):
|
||||
# Uvicorn ships an ANSI-coloured copy of the message via extra=; letting
|
||||
# it through puts escape codes in Loki.
|
||||
payload = _emit(
|
||||
_record(name="uvicorn.error", msg="Started", color_message="\x1b[36mStarted\x1b[0m")
|
||||
)
|
||||
assert "color_message" not in payload
|
||||
assert "\x1b" not in json.dumps(payload)
|
||||
|
||||
def test_non_json_native_extra_is_stringified(self):
|
||||
payload = _emit(_record(obj=object()))
|
||||
assert isinstance(payload["obj"], str)
|
||||
|
||||
def test_secretstr_extra_stays_masked(self):
|
||||
# Config secrets are SecretStr precisely so an accidental log can't leak
|
||||
# them; stringification must preserve that.
|
||||
from pydantic import SecretStr
|
||||
|
||||
payload = _emit(_record(secret=SecretStr("hs_pat_supersecret")))
|
||||
assert "supersecret" not in json.dumps(payload)
|
||||
|
||||
def test_exception_is_captured(self):
|
||||
try:
|
||||
raise ValueError("boom")
|
||||
except ValueError:
|
||||
import sys
|
||||
|
||||
record = _record(level=logging.ERROR, msg="failed")
|
||||
record.exc_info = sys.exc_info()
|
||||
payload = _emit(record)
|
||||
assert "ValueError: boom" in payload["exc"]
|
||||
|
||||
def test_thread_name_included_only_off_main_thread(self):
|
||||
# Which execution context logged a line is the first question when
|
||||
# debugging a call across the asyncio/Sippy/PJSUA2 boundary.
|
||||
on_main = _record()
|
||||
on_main.threadName = "MainThread"
|
||||
assert "thread" not in _emit(on_main)
|
||||
|
||||
off_main = _record()
|
||||
off_main.threadName = "sippy-ed"
|
||||
assert _emit(off_main)["thread"] == "sippy-ed"
|
||||
|
||||
|
||||
class TestAccessLogFields:
|
||||
def _access(self, status=200):
|
||||
return _emit(
|
||||
_record(
|
||||
name="uvicorn.access",
|
||||
msg='%s - "%s %s HTTP/%s" %d',
|
||||
args=("127.0.0.1:5050", "GET", "/api/v1/calls", "1.1", status),
|
||||
)
|
||||
)
|
||||
|
||||
def test_arg_tuple_becomes_structured_fields(self):
|
||||
payload = self._access()
|
||||
assert payload["method"] == "GET"
|
||||
assert payload["path"] == "/api/v1/calls"
|
||||
assert payload["client_addr"] == "127.0.0.1:5050"
|
||||
assert payload["http_version"] == "1.1"
|
||||
|
||||
def test_status_code_is_a_number_not_a_string(self):
|
||||
# So Loki can range-filter on it (status_code >= 500).
|
||||
assert self._access(503)["status_code"] == 503
|
||||
|
||||
def test_access_line_is_not_pre_formatted(self):
|
||||
# The whole point: no interpolated request line to regex back apart.
|
||||
assert "msg" not in self._access()
|
||||
|
||||
def test_unexpected_arg_shape_falls_back_to_message(self):
|
||||
# A logging path that raises would take out the request; degrade instead.
|
||||
payload = _emit(_record(name="uvicorn.access", msg="just a string", args=None))
|
||||
assert payload["msg"] == "just a string"
|
||||
|
||||
|
||||
class TestConfigureLogging:
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore(self):
|
||||
root = logging.getLogger()
|
||||
saved = root.handlers[:], root.level
|
||||
yield
|
||||
root.handlers[:] = saved[0]
|
||||
root.setLevel(saved[1])
|
||||
|
||||
def _uvicorn_defaults(self):
|
||||
"""Reproduce what uvicorn does to its loggers at startup."""
|
||||
from uvicorn.config import LOGGING_CONFIG
|
||||
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
|
||||
def test_takes_over_uvicorn_handlers(self):
|
||||
self._uvicorn_defaults()
|
||||
access = logging.getLogger("uvicorn.access")
|
||||
assert access.propagate is False # uvicorn's default, the problem
|
||||
|
||||
configure_logging("json", "info")
|
||||
assert access.propagate is True
|
||||
assert access.handlers == []
|
||||
|
||||
def test_json_mode_installs_json_formatter(self):
|
||||
configure_logging("json", "info")
|
||||
assert isinstance(logging.getLogger().handlers[0].formatter, JSONFormatter)
|
||||
|
||||
def test_text_mode_does_not(self):
|
||||
configure_logging("text", "info")
|
||||
assert not isinstance(logging.getLogger().handlers[0].formatter, JSONFormatter)
|
||||
|
||||
def test_is_idempotent(self):
|
||||
# Called at import and again in lifespan; a second call must replace the
|
||||
# handler, not add one, or every line is emitted twice.
|
||||
configure_logging("json", "info")
|
||||
configure_logging("json", "info")
|
||||
assert len(logging.getLogger().handlers) == 1
|
||||
|
||||
def test_respects_log_level(self):
|
||||
configure_logging("json", "warning")
|
||||
assert logging.getLogger().level == logging.WARNING
|
||||
|
||||
|
||||
class TestSettings:
|
||||
def test_defaults_to_text(self):
|
||||
# A JSON-only default would make local development worse.
|
||||
assert Settings(database_url="sqlite+aiosqlite:///:memory:").log_format == "text"
|
||||
|
||||
def test_reads_log_format_env(self, monkeypatch):
|
||||
monkeypatch.setenv("LOG_FORMAT", "json")
|
||||
assert Settings(database_url="sqlite+aiosqlite:///:memory:").log_format == "json"
|
||||
@@ -6,12 +6,32 @@ Uses the FastMCP in-memory client (no network, no mounted app).
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from config import Settings
|
||||
import db.database as dbmod
|
||||
from config import Settings, get_settings
|
||||
from core.dial_plan import is_emergency_number
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import Base
|
||||
from mcp_server.server import create_mcp_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db(monkeypatch):
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
EXPECTED_TOOLS = {
|
||||
"make_call",
|
||||
"get_call_status",
|
||||
@@ -43,11 +63,64 @@ class TestToolSurface:
|
||||
tools = {t.name for t in await client.list_tools()}
|
||||
assert tools == EXPECTED_TOOLS
|
||||
|
||||
async def test_auth_configured_when_token_given(self):
|
||||
assert create_mcp_server(lambda: None, api_token="sekrit").auth is not None
|
||||
async def test_no_fastmcp_auth_configured(self):
|
||||
# Auth for /mcp is enforced by the ASGI _owner_only_mcp guard in
|
||||
# main.py, not on the FastMCP instance itself.
|
||||
assert create_mcp_server(lambda: None).auth is None
|
||||
|
||||
|
||||
class TestMcpOwnerGuard:
|
||||
"""The ASGI wrapper gates /mcp before the inner app runs."""
|
||||
|
||||
def _wrapped(self):
|
||||
import main
|
||||
|
||||
calls = {"inner": 0}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
calls["inner"] += 1
|
||||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b"ok"})
|
||||
|
||||
return main._owner_only_mcp(inner), calls
|
||||
|
||||
async def _run(self, app, headers):
|
||||
sent = []
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def send(msg):
|
||||
sent.append(msg)
|
||||
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/mcp/",
|
||||
"headers": headers,
|
||||
"query_string": b"",
|
||||
}
|
||||
await app(scope, receive, send)
|
||||
status = next(m["status"] for m in sent if m["type"] == "http.response.start")
|
||||
return status
|
||||
|
||||
async def test_missing_token_401(self, monkeypatch, mem_db):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
|
||||
app, calls = self._wrapped()
|
||||
status = await self._run(app, headers=[])
|
||||
assert status == 401
|
||||
assert calls["inner"] == 0
|
||||
|
||||
async def test_dev_owner_passes(self, monkeypatch, mem_db):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
app, calls = self._wrapped()
|
||||
status = await self._run(app, headers=[])
|
||||
assert status == 200
|
||||
assert calls["inner"] == 1
|
||||
|
||||
|
||||
class TestGatewayResolution:
|
||||
async def test_tool_errors_cleanly_before_gateway_ready(self):
|
||||
mcp = create_mcp_server(lambda: None)
|
||||
|
||||
82
tests/test_oauth_metadata.py
Normal file
82
tests/test_oauth_metadata.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
OAuth discovery metadata tests (RFC 9728 / RFC 8414 / RFC 7591).
|
||||
|
||||
MCP clients that get a 401 from /mcp perform OAuth discovery. These
|
||||
endpoints are unauthenticated and served straight from main.app.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import main
|
||||
from config import get_settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch):
|
||||
# These endpoints derive their URLs from PUBLIC_BASE_URL when it is set,
|
||||
# falling back to the request's Host header. Pin it empty so the assertions
|
||||
# below exercise the header path and can't be overridden by a developer's
|
||||
# real .env.
|
||||
monkeypatch.setattr(get_settings(), "public_base_url", "")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
class TestProtectedResourceMetadata:
|
||||
async def test_resource_advertises_mcp_path(self, client):
|
||||
resp = await client.get("/.well-known/oauth-protected-resource")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
# mcp-remote verifies this matches the URL it connected to.
|
||||
assert body["resource"] == "http://test/mcp"
|
||||
assert body["authorization_servers"] == ["http://test"]
|
||||
|
||||
async def test_mcp_suffixed_variant(self, client):
|
||||
resp = await client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["resource"] == "http://test/mcp"
|
||||
|
||||
|
||||
class TestAuthorizationServerMetadata:
|
||||
async def test_advertises_casdoor_when_enabled(self, monkeypatch, client):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||
resp = await client.get("/.well-known/oauth-authorization-server")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["issuer"] == "https://id.example.test"
|
||||
assert body["jwks_uri"] == "https://id.example.test/.well-known/jwks"
|
||||
assert body["registration_endpoint"] == "http://test/register"
|
||||
|
||||
async def test_dev_mode_advertises_local(self, monkeypatch, client):
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
resp = await client.get("/.well-known/oauth-authorization-server")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["issuer"] == "http://test"
|
||||
assert body["authorization_endpoint"] == "http://test/auth/login"
|
||||
|
||||
|
||||
class TestDynamicRegistration:
|
||||
async def test_registers_client(self, client):
|
||||
resp = await client.post(
|
||||
"/register",
|
||||
json={"redirect_uris": ["http://localhost/cb"], "client_name": "test"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert "client_id" in body
|
||||
assert body["redirect_uris"] == ["http://localhost/cb"]
|
||||
|
||||
async def test_rejects_missing_redirect_uris(self, client):
|
||||
resp = await client.post("/register", json={"client_name": "test"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"] == "invalid_redirect_uri"
|
||||
|
||||
async def test_rejects_non_json(self, client):
|
||||
resp = await client.post(
|
||||
"/register", content=b"not json", headers={"content-type": "application/json"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
185
tests/test_rate_limit.py
Normal file
185
tests/test_rate_limit.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Rate-limiting tests.
|
||||
|
||||
The limiter guards the unauthenticated `/auth/*` edge — the only routes that
|
||||
must answer before an identity exists. Everything else is owner-gated, so a
|
||||
limit there would mostly throttle the single legitimate operator.
|
||||
|
||||
The properties worth pinning: the cap actually blocks, windows expire, clients
|
||||
and routes don't share a bucket, the store can't grow without bound, and
|
||||
identity comes from the socket peer rather than a spoofable header.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
import main
|
||||
from core.rate_limit import RateLimiter, client_key, get_limiter, rate_limit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_limiter():
|
||||
get_limiter().reset()
|
||||
yield
|
||||
get_limiter().reset()
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
def test_allows_up_to_the_limit(self):
|
||||
rl = RateLimiter(limit=3, window_seconds=60)
|
||||
assert [rl.check("k", now=100.0)[0] for _ in range(3)] == [True, True, True]
|
||||
|
||||
def test_blocks_past_the_limit(self):
|
||||
rl = RateLimiter(limit=3, window_seconds=60)
|
||||
for _ in range(3):
|
||||
rl.check("k", now=100.0)
|
||||
allowed, retry_after = rl.check("k", now=100.0)
|
||||
assert allowed is False
|
||||
assert retry_after > 0
|
||||
|
||||
def test_window_expiry_resets_the_count(self):
|
||||
rl = RateLimiter(limit=2, window_seconds=60)
|
||||
rl.check("k", now=100.0)
|
||||
rl.check("k", now=100.0)
|
||||
assert rl.check("k", now=100.0)[0] is False
|
||||
# A full window later, the caller is welcome again.
|
||||
assert rl.check("k", now=161.0)[0] is True
|
||||
|
||||
def test_retry_after_shrinks_as_the_window_drains(self):
|
||||
rl = RateLimiter(limit=1, window_seconds=60)
|
||||
rl.check("k", now=100.0)
|
||||
early = rl.check("k", now=110.0)[1]
|
||||
late = rl.check("k", now=150.0)[1]
|
||||
assert early > late >= 1
|
||||
|
||||
def test_clients_do_not_share_a_bucket(self):
|
||||
rl = RateLimiter(limit=1, window_seconds=60)
|
||||
assert rl.check("auth:me:1.1.1.1", now=100.0)[0] is True
|
||||
# A different client must be unaffected by the first one's usage.
|
||||
assert rl.check("auth:me:2.2.2.2", now=100.0)[0] is True
|
||||
|
||||
def test_routes_do_not_share_a_bucket(self):
|
||||
rl = RateLimiter(limit=1, window_seconds=60)
|
||||
assert rl.check("auth:me:1.1.1.1", now=100.0)[0] is True
|
||||
assert rl.check("auth:callback:1.1.1.1", now=100.0)[0] is True
|
||||
|
||||
def test_per_call_limit_overrides_the_default(self):
|
||||
rl = RateLimiter(limit=100, window_seconds=60)
|
||||
rl.check("k", limit=1, now=100.0)
|
||||
assert rl.check("k", limit=1, now=100.0)[0] is False
|
||||
|
||||
def test_bucket_store_is_bounded(self):
|
||||
# Otherwise a spray of source addresses is itself a memory exhaustion
|
||||
# vector — the thing the limiter exists to prevent.
|
||||
rl = RateLimiter(limit=5, window_seconds=60, max_clients=10)
|
||||
for i in range(50):
|
||||
rl.check(f"client-{i}", now=100.0 + i)
|
||||
assert len(rl._buckets) <= 10
|
||||
|
||||
def test_eviction_drops_oldest_first(self):
|
||||
rl = RateLimiter(limit=5, window_seconds=600, max_clients=3)
|
||||
for i in range(4):
|
||||
rl.check(f"client-{i}", now=100.0 + i)
|
||||
assert "client-0" not in rl._buckets
|
||||
assert "client-3" in rl._buckets
|
||||
|
||||
|
||||
class TestClientKey:
|
||||
def _request(self, peer: str | None, headers: dict | None = None):
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/auth/me",
|
||||
"headers": [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()],
|
||||
"client": (peer, 12345) if peer else None,
|
||||
}
|
||||
from starlette.requests import Request
|
||||
|
||||
return Request(scope)
|
||||
|
||||
def test_uses_the_socket_peer(self):
|
||||
assert client_key(self._request("10.0.0.5"), "auth:me") == "auth:me:10.0.0.5"
|
||||
|
||||
def test_ignores_x_forwarded_for(self):
|
||||
# Trusting a spoofable header would let one client present as
|
||||
# thousands, making the limiter worse than useless.
|
||||
key = client_key(
|
||||
self._request("10.0.0.5", {"X-Forwarded-For": "1.2.3.4"}), "auth:me"
|
||||
)
|
||||
assert key == "auth:me:10.0.0.5"
|
||||
assert "1.2.3.4" not in key
|
||||
|
||||
def test_missing_peer_does_not_crash(self):
|
||||
assert client_key(self._request(None), "auth:me") == "auth:me:unknown"
|
||||
|
||||
|
||||
class TestDependency:
|
||||
"""The FastAPI integration: a 429 with a Retry-After header."""
|
||||
|
||||
def _app(self, limit=2):
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/limited", dependencies=[Depends(rate_limit("test", limit=limit))])
|
||||
async def limited():
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/unlimited")
|
||||
async def unlimited():
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
async def _get(self, app, path, n=1):
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
return [await c.get(path) for _ in range(n)]
|
||||
|
||||
async def test_returns_429_past_the_limit(self):
|
||||
responses = await self._get(self._app(limit=2), "/limited", n=3)
|
||||
assert [r.status_code for r in responses] == [200, 200, 429]
|
||||
|
||||
async def test_429_carries_retry_after(self):
|
||||
responses = await self._get(self._app(limit=1), "/limited", n=2)
|
||||
blocked = responses[-1]
|
||||
assert blocked.status_code == 429
|
||||
assert int(blocked.headers["retry-after"]) >= 1
|
||||
|
||||
async def test_unlimited_routes_are_untouched(self):
|
||||
responses = await self._get(self._app(limit=1), "/unlimited", n=10)
|
||||
assert {r.status_code for r in responses} == {200}
|
||||
|
||||
|
||||
class TestAuthRoutesAreLimited:
|
||||
"""The wiring: the unauthenticated edge is covered, the rest is not."""
|
||||
|
||||
def _is_limited(self, path: str) -> bool:
|
||||
"""True if the route carries a dependency built by `rate_limit`.
|
||||
|
||||
Identified by the closure's qualname rather than a string search, so
|
||||
this can't pass on an unrelated dependency that happens to stringify
|
||||
similarly.
|
||||
"""
|
||||
for route in main.app.routes:
|
||||
if getattr(route, "path", None) != path:
|
||||
continue
|
||||
return any(
|
||||
getattr(d.dependency, "__qualname__", "").startswith("rate_limit")
|
||||
for d in getattr(route, "dependencies", [])
|
||||
)
|
||||
raise AssertionError(f"route {path} not found")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path", ["/auth/login", "/auth/callback", "/auth/me", "/auth/refresh-callback"]
|
||||
)
|
||||
def test_unauthenticated_auth_routes_are_limited(self, path):
|
||||
assert self._is_limited(path)
|
||||
|
||||
def test_owner_gated_routes_are_not_limited(self):
|
||||
# They're already behind is_owner; limiting them would throttle the
|
||||
# single legitimate operator.
|
||||
assert not self._is_limited("/api/v1/calls/active")
|
||||
|
||||
def test_logout_is_not_limited(self):
|
||||
# Pure redirect builder — no I/O, nothing to exhaust.
|
||||
assert not self._is_limited("/auth/logout")
|
||||
@@ -9,7 +9,6 @@ through the shared data layer in services/call_persistence.py.
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
@@ -116,7 +115,9 @@ class TestInboundPolicy:
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
||||
# Dev-owner mode: tokenless requests resolve to the owner. auth's DB
|
||||
# session comes through the same get_db override below.
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
|
||||
@@ -1,27 +1,57 @@
|
||||
"""
|
||||
WebSocket event-stream tests.
|
||||
|
||||
The socket is refused (4401) without the bearer token, and an
|
||||
authorized client immediately receives the synthetic trunk-status
|
||||
event followed by the replayed recent history.
|
||||
The socket is owner-gated: refused (4401) when SSO is enabled and no
|
||||
credential is supplied, and — in dev-owner mode (SSO disabled) — an
|
||||
authorized client immediately receives the synthetic trunk-status event
|
||||
followed by the replayed recent history.
|
||||
|
||||
The WS `_authorize` resolves the owner via a DB session, so an in-memory
|
||||
SQLite database is wired in (StaticPool, shared across the TestClient
|
||||
thread) mirroring tests/test_data_layer.py.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from starlette.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
import db.database as dbmod
|
||||
import main
|
||||
from config import Settings, get_settings
|
||||
from core.gateway import AIPSTNGateway
|
||||
from db.database import Base
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_app(monkeypatch):
|
||||
monkeypatch.setattr(get_settings(), "api_token", SecretStr("tok"))
|
||||
def mem_db(monkeypatch):
|
||||
"""Synchronous setup of an in-memory SQLite DB shared with the app."""
|
||||
engine = create_async_engine(
|
||||
"sqlite+aiosqlite:///:memory:",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
async def _create():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
asyncio.run(_create())
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(dbmod, "_engine", engine)
|
||||
monkeypatch.setattr(dbmod, "_session_factory", factory)
|
||||
yield
|
||||
asyncio.run(engine.dispose())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_app(monkeypatch, mem_db):
|
||||
"""Dev-owner mode: a tokenless WS connect resolves the owner."""
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", False)
|
||||
gateway = AIPSTNGateway(settings=Settings())
|
||||
main.app.state.gateway = gateway
|
||||
yield gateway
|
||||
@@ -38,7 +68,11 @@ def _publish(gateway, call_id: str) -> None:
|
||||
|
||||
|
||||
class TestEventStream:
|
||||
def test_refused_without_token(self, ws_app):
|
||||
def test_refused_without_credential(self, monkeypatch, mem_db):
|
||||
"""SSO enabled + no token → the socket is closed with 4401."""
|
||||
monkeypatch.setattr(get_settings().casdoor, "enabled", True)
|
||||
monkeypatch.setattr(get_settings().casdoor, "endpoint", "https://id.example.test")
|
||||
monkeypatch.setattr(get_settings(), "owner_name", "owner@example.test")
|
||||
client = TestClient(main.app)
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect("/ws/events"):
|
||||
@@ -50,7 +84,7 @@ class TestEventStream:
|
||||
_publish(ws_app, "call_ws2")
|
||||
|
||||
client = TestClient(main.app)
|
||||
with client.websocket_connect("/ws/events?token=tok") as ws:
|
||||
with client.websocket_connect("/ws/events") as ws:
|
||||
first = ws.receive_json()
|
||||
assert first["type"] == EventType.SIP_TRUNK_REGISTRATION_FAILED.value
|
||||
replayed = [ws.receive_json() for _ in range(2)]
|
||||
@@ -58,9 +92,7 @@ class TestEventStream:
|
||||
|
||||
def test_per_call_stream_filters(self, ws_app):
|
||||
client = TestClient(main.app)
|
||||
with client.websocket_connect(
|
||||
"/ws/calls/call_target/events?token=tok"
|
||||
) as ws:
|
||||
with client.websocket_connect("/ws/calls/call_target/events") as ws:
|
||||
_publish(ws_app, "call_other")
|
||||
_publish(ws_app, "call_target")
|
||||
msg = ws.receive_json()
|
||||
|
||||
Reference in New Issue
Block a user