A container-based IP intercom with direct (point-to-point) and group (open-mic + PTT) calling, a custom TypeScript PBX core (drachtio + rtpengine), a real-browser WebRTC softphone ("turret", modeled on a trading-desk turret), and an admin controller console for directory users, groups, live status, health, audit, and database backup. Built for a routed / multi-subnet deployment.
turret (browser: getUserMedia/RTCPeerConnection, wss:// or ws:// via nginx)
│ SIP/WS (WebRTC media, DTLS-SRTP)
▼
pbx-core (drachtio + TS) ◀─ ng ─▶ rtpengine (media anchor; bridges DTLS-SRTP ↔ plain RTP)
│ group calls (*8<code>) B2BUA'd to ▼
freeswitch (mod_conference open-mic mixer)
controller-frontend (nginx: console + /turret/ + /ws proxy, HTTP+HTTPS)
──/api/v1──▶ controller-backend
(Express+TS, Postgres)
Six compose services: postgres, controller-backend, controller-frontend
(also serves the turret at /turret/), pbx-core, rtpengine, freeswitch.




``
- Direct intercom — dial a registered extension; pbx-core injects an
auto-answer header (
Call-Info/Alert-Info) and anchors media through rtpengine. Real SIP hold/resume (a genuine re-INVITE, not just local mute) works both directions of a call. - Group open-mic — pbx-core B2BUAs a group call (
*8<dial_code>) straight to FreeSWITCH, which joins it into amod_conferenceroom of the same name. Every unmuted member hears every other one — no custom mixing logic. PTT floor control is done client-side (gating the local mic track), not at the mixer. - turret/ — the only endpoint type in this system: a real-browser
softphone (genuine
getUserMedia/RTCPeerConnection, no headless shims) modeled on a trading voice turret. Three independent audio channels (Handset A, Handset B, Speaker) plus a dedicated Intercom channel with a configurable auto-answer toggle, driven by a directory user login (extension + password, a hot-desk model — not tied to one physical device). Click-to-arm channel selection for group calls, move a live call between channels mid-call, per-channel hold/resume and mute/PTT. - rtpengine bridges a genuine WebRTC leg (the turret) to plain RTP
(FreeSWITCH) automatically — including the trickier case of two
simultaneous WebRTC legs (turret↔turret), which needed its own
ICE-litehandling distinct from the single-WebRTC-leg case.
- Live — real registered turrets and in-progress calls, polled straight from pbx-core's own in-memory state (pbx-core has no database by design; a small admin HTTP endpoint on pbx-core exposes this, enriched with friendly names by the controller). Call-type badges (ICM/GRP-ICM) and per-type metrics.
- Groups / Directory Users — full CRUD (create/edit/delete), button assignments (direct or group targets) per directory user.
- Health (admin only) — host CPU/mem/disk, DB size/stats, endpoint statuses, pbx-core reachability, and a live container-log viewer (reads other containers' logs over the podman API socket).
- Audit (admin only) — searchable history of
audit_log(who did what, when — logins, CRUD, call start/end, PTT grants, including turret-placed calls). - Database (admin only) —
pg_dumpbackup download, restore from an uploaded dump,VACUUM ANALYZE, table size/bloat stats, retention/purge foraudit_log/intercom_sessions, and gzip archive + optional SCP transfer. - Users / JWT / RBAC (viewer/editor/admin), append-only audit log,
background endpoint health polling — conventions deliberately mirror
Chris's Walk the Nxt Floor project (
pgpool, numbered migrations, JWT roles,audit_logshape).
Both media components are built from Dockerfiles under images/ rather
than pulled as pre-built images, because the obvious public images don't
work: SignalWire's FreeSWITCH image is behind an auth-gated registry, and
the only :latest rtpengine on Docker Hub is ~5 years old.
images/rtpengineinstallsrtpengine-daemonfrom the Debian bookworm package and runs it userspace-only (--table=-1) so it needs no kernel module in the container.images/freeswitchbuildsFROM drachtio/drachtio-freeswitch-base, overlaying a minimalmodules.conf, asofia.confincluding our SIP profile, the intercom SIP profile + dialplan, and an open-mic conference profile. The profile's bind IP is set explicitly viaFREESWITCH_BIND_IP(seeentrypoint.sh) rather than FreeSWITCH's own$${local_ip_v4}auto-detection — on a multi-homed host, that auto-detection can pick a different interface than the one pbx-core is configured to dial, which silently breaks every group call.
cp .env.example .env
# edit .env: set RTPENGINE_PUBLIC_IP to this host's routed/LAN IP,
# and change JWT_SECRET / ADMIN_PASSWORD / DRACHTIO_SECRET
podman-compose up -d --build- Controller UI:
http://<host>:8080orhttps://<host>:8443— sign in withADMIN_EMAIL/ADMIN_PASSWORDfrom.env. - Turret:
http://<host>:8080/turret/(localhost only —getUserMedianeeds a secure context, andlocalhostis browser-exempted) orhttps://<host>:8443/turret/(works from a genuinely separate machine too, once you click through the self-signed cert warning once per browser — see "TLS" below). - Controller API:
http://<host>:3100/api/v1/... - pbx-core SIP/WS:
<host>:8088, SIP UDP/TCP on:5060. - Postgres: exposed on host
5433for direct inspection if needed.
pbx-core, rtpengine, and freeswitch run with network_mode: host —
required so rtpengine can anchor media reachably for endpoints on other
subnets in a routed deployment.
The console's Health page can show live logs from every container over the podman API socket. This needs one host-level prerequisite (rootless podman):
systemctl --user enable --now podman.socketOn SELinux-enforcing hosts (Fedora/RHEL), controller-backend also runs
with security_opt: label:disable in docker-compose.yml — SELinux
denies a confined container from connecting to the podman socket even with
the file permissions otherwise correct, and no stock boolean covers that
specific case. This widens that one container's blast radius (full
control-plane visibility into every container on the host, not just this
project's 6) — accepted deliberately for this lab; skip the log viewer
entirely if you'd rather not take that tradeoff.
controller-frontend generates a self-signed cert on first start
(persisted in a volume, so it survives redeploys — no new warning every
time) and serves both plain HTTP (:8080) and HTTPS (:8443) side by
side; nothing extra to configure beyond RTPENGINE_PUBLIC_IP already
being set in .env (reused as the cert's SAN entry). The turret's SIP/WS
signaling is proxied through the same nginx at /ws, so it always
connects back to whichever origin the page itself loaded from — no
separate host/port to hand it. First visit over HTTPS: click through the
browser's self-signed-certificate warning once (per browser/device).
- Console → Directory Users panel → create a person (name, extension, turret-login password), then add them a direct button (target extension) and/or a group button.
- Open
http://localhost:8080/turret/, log in with the extension + turret password. - Press a button — direct buttons auto-connect instantly (pbx-core's auto-answer headers); group buttons join that group's FreeSWITCH conference on whichever channel is currently armed (click an idle Handset/Speaker strip to arm it before pressing a group button). Press the same button again to hang up.
- Testing two turrets on one machine: use genuinely separate browser
contexts (an Incognito window, or a different browser) — duplicated
tabs share
sessionStorageand will fight over the same login.
- Console → Groups panel → create a group (mode Talkback for open-mic, Announce for one-way paging). Dial code defaults to a slug of the name.
- Tick which endpoints/directory users are members via their button assignments, or add a group button to a directory user directly.
- Have a turret press the group button — pbx-core routes
*8<dial_code>to FreeSWITCH, which joins every talking member into the same conference. Everyone unmuted hears everyone else.
docker-compose.yml — full stack (this is what you run)
.env.example — copy to .env and fill in
pod.yaml — alternative: podman play kube (pbx-core+rtpengine only)
migrations/ — controller's numbered SQL migrations
controller/
backend/ — Express API: auth, endpoints, groups, intercom,
directory users, turret login, system health/
audit/logs, admin db backup/restore
frontend/ — console (Vite/React, built + served by nginx) —
its Containerfile also builds turret/ as a
second stage, served at /turret/;
entrypoint.sh generates a self-signed TLS
cert on first start
turret/ — real-browser trading-turret softphone (Vite/
React/TS): genuine getUserMedia/
RTCPeerConnection, 2 Handsets + 1 Speaker +
Intercom channel, directory-user login
pbx-core/ — drachtio SIP core: registrar, direct/group
routing, rtpengine wrapper, admin status API
images/
rtpengine/ — rtpengine media relay image (Debian package, userspace mode)
freeswitch/ — group open-mic mixer image (FROM drachtio FS base + conf overlay)
patches/
endpoint-auto-answer.md — reference notes for the separate, unrelated
container-sip-endpoint sibling project
- SIP registration is authless and the controller's endpoint REST calls are unauthenticated by default (lab prototype) — add SIP digest auth and endpoint-side API tokens before this leaves the lab.
- TLS is self-signed (see "TLS" above) — fine for a private LAN lab, but
every browser/device needs its own one-time trust exception. Swap in a
real cert by mounting it over
/etc/nginx/tls/{cert,key}.pemif you ever have one (e.g. an internal CA). HEALTH_INTERVAL_MS/ retention are env-configurable oncontroller-backend, same pattern as Walk the Nxt Floor'ssipHealthService.- Local dev without containers:
cd controller/backend && npm install && npm run devneedsPGHOST/PGPORTetc pointed at a reachable Postgres; notemigrate.ts'sMIGRATIONS_DIRassumes the container layout — adjust the relative path if running straight fromsrc/.
- Handset A/B repurposed for real SIP extensions on an external PBX —
a shared-line-appearance pattern (global
lines, many-to-many assignment to directory users, "capture" pickup from the turret UI). Large future scope, detailed design notes inCLAUDE.md, not yet designed in full. - Announce-zone listen-only members aren't dialled out yet (only talkers are).
- API keys, WebSocket live session streaming, SIP digest auth — none built yet.
- A turret joining a group call via its Speaker channel specifically (each half — turret↔turret and turret-in-group — verified independently, not yet together), and 3 simultaneous channels active at once on one turret, haven't been explicitly exercised.